ndr_lookup 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ef8ffa13786096c8d95f8d8454d8b1e60611d71c9d54a72f57d6d64a8c13eeda
4
+ data.tar.gz: 87ded454a35954c619f0b785af5a1eaf6b95070d7a628f60d56dd662f3de628f
5
+ SHA512:
6
+ metadata.gz: d70a25899667f9006538f119a430fff5984a62aa3e97390d0e0fa99b60da33f4084ae6c2f49527d36f84b49d848568738a206d176b46bed33b1baf83aa96a4e1
7
+ data.tar.gz: 68efe8f25d0763497b39d1e1e847b523a75e5c63123c4fcf4b618a14bad0d70a834d5c1e2155d14972e4be6ce6b79fff351f81ad3a0aaba966155855c881156d
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ ## [Unreleased]
2
+ * no unreleased changes
3
+
4
+ ## 0.1.4 / 2025-09-19
5
+ ### Added
6
+ * Added NHS FHIR ODT client
7
+
8
+ ## 0.1.3 / 2024-11-19
9
+ ### Fixed
10
+ * Support Ruby 3.3, Rails 7.1, 7.2 and 8.0. Drop support for Ruby 3.0, Rails 6.1
11
+
12
+ ### Added
13
+ * Support Ruby 3.2. Drop support for Ruby 2.7, Rails 6.0
14
+
15
+ ## 0.1.2 / 2022-12-08
16
+ ### Fixed
17
+ * Drop support for Rails 5.2
18
+ * Support Ruby 3.1, Rails 7.0
19
+
20
+ ## 0.1.1 / 2022-11-15
21
+ ### Added
22
+ * Added NHS Digital ODS API client
23
+ * Drop support for Ruby 2.6
24
+
25
+ ## 0.1.0 / Unreleased
26
+ ### Added
27
+ * Added existing ArcGIS LocatorHub address `Rectify` client
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at timgentry@users.noreply.github.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 timgentry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # NdrLookup [![Build Status](https://github.com/NHSDigital/ndr_lookup/workflows/Test/badge.svg)](https://github.com/NHSDigital/ndr_lookup/actions?query=workflow%3Atest)
2
+
3
+ This is the NHS Digital (NHSD) National Disease Registers (NDR) Lookup ruby gem,
4
+ providing:
5
+
6
+ 1. an ArcGIS LocatorHub API client
7
+ 2. NHS Digital Organisation Data Service API Client
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'ndr_lookup'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle
20
+
21
+ Or install it yourself as:
22
+
23
+ $ gem install ndr_lookup
24
+
25
+ ## Usage
26
+
27
+ ### ArcGIS LocatorHub API Client
28
+
29
+ `NdrLookup::LocatorHub::Client` is a client to access the LocatorHub API to rectify given addresses.
30
+
31
+ The client isn't included by default, so add the following to your code:
32
+
33
+ ```ruby
34
+ require 'ndr_lookup/locator_hub/client'
35
+ ```
36
+
37
+ It requires some manual setup:
38
+
39
+ Configuration | Description
40
+ --- | ---
41
+ `domain` | A string of the authenicating domain name.
42
+ `username` | A string of the authenticating username.
43
+ `password` | A string of the authenticating password.
44
+
45
+ Within a Rails application, you should do this in an initializer, preferably using encrypted credentials, e.g.:
46
+
47
+ ```ruby
48
+ # This file configures NdrLookup.
49
+ Rails.application.config.to_prepare do
50
+ NdrLookup::LocatorHub::Client.domain = Rails.application.credentials.production[:locator_hub_api][:domain]
51
+ NdrLookup::LocatorHub::Client.username = Rails.application.credentials.production[:locator_hub_api][:username]
52
+ NdrLookup::LocatorHub::Client.password = Rails.application.credentials.production[:locator_hub_api][:password]
53
+ end
54
+ ```
55
+
56
+ ### NHS Digital Organisation Data Service API Client
57
+
58
+ NdrLookup::NhsdOds::Client is a client to access the NHS Digital ODS API search and sync endpoints.
59
+
60
+ Sync will return a JSON format payload for all organisations that have been updated since the specified date:
61
+
62
+ ```ruby
63
+ require 'ndr_lookup/nhsd_ods/client'
64
+
65
+ organisation_ids = NdrLookup::NhsdOds::Client.sync(Date.new(2019, 6, 14))
66
+ ```
67
+
68
+ *NOTE* that the client isn't included by default, so you have to require it specifically.
69
+
70
+ Search will allow you to search based on parameters specified by ODS
71
+ https://digital.nhs.uk/services/organisation-data-service/guidance-for-developers/search-endpoint#parameters
72
+
73
+ For example:
74
+
75
+ ```ruby
76
+ require 'ndr_lookup/nhsd_ods/client'
77
+
78
+ results = NdrLookup::NhsdOds::Client.search(Name: 'NHS Digital')
79
+ ```
80
+
81
+ NdrLookup::NhsdOds::Organisation will enable you to access the NHS Digital ODS API to find a specific organisation.
82
+
83
+ Lookup will return an Organisation object with model like behaviours:
84
+
85
+ ```ruby
86
+ require 'ndr_lookup/nhsd_ods/organisation'
87
+
88
+ organisation = NdrLookup::NhsdOds::Organisation.find('X26')
89
+ ```
90
+
91
+ NdrLookup::NhsdOds::CodeSystems will enable you to access the NHS Digital ODS API to find the meta data information included in the payload records returned via the endpoints. CodeSystems currently includes roles, relationships and record classes.
92
+
93
+ ```ruby
94
+ require 'ndr_lookup/nhsd_ods/code_systems'
95
+
96
+ role = NdrLookup::NhsdOds::Role.find('RO197')
97
+ first_relationship = NdrLookup::NhsdOds::Rel.first
98
+ all_record_classes = NdrLookup::NhsdOds::Recordclass.all
99
+ ```
100
+
101
+ ## Development
102
+
103
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
104
+
105
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
106
+
107
+ ## Contributing
108
+
109
+ Bug reports and pull requests are welcome on GitHub at https://github.com/NHSDigital/ndr_lookup. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
110
+
111
+ ## License
112
+
113
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
114
+
115
+ ## Code of Conduct
116
+
117
+ Everyone interacting in the NdrLookup project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/NHSDigital/ndr_lookup/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+ require 'ndr_dev_support/tasks'
4
+ require 'ndr_lookup/tasks'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,111 @@
1
+ require 'active_resource'
2
+ require 'active_support'
3
+ require 'active_support/core_ext'
4
+
5
+ module NdrLookup
6
+ module Fhir
7
+ # Client for interacting with the NHS Digital FHIR API
8
+ class Base < ActiveResource::Base
9
+ class ApiError < StandardError; end
10
+ class ResourceNotFound < ApiError; end
11
+ class InvalidResponse < ApiError; end
12
+
13
+ class << self
14
+ attr_writer :additional_headers
15
+
16
+ def additional_headers
17
+ @additional_headers ||= {}
18
+ end
19
+
20
+ def sync
21
+ raise 'Must be defined in subclasses!'
22
+ end
23
+
24
+ # Finds a specific FHIR resource by type and ID
25
+ # @return [Hash] Parsed FHIR resource
26
+ def find(resource_type, id)
27
+ response = connection.get(
28
+ "#{endpoint}/#{resource_type}/#{id}",
29
+ headers
30
+ )
31
+ JSON.parse(response.body)
32
+ rescue ActiveResource::ResourceNotFound
33
+ raise ResourceNotFound, "#{resource_type} with ID '#{id}' not found"
34
+ rescue JSON::ParserError
35
+ raise InvalidResponse, 'Invalid JSON response from server'
36
+ rescue StandardError => e
37
+ raise ApiError, "Unexpected error: #{e.message}"
38
+ end
39
+
40
+ def endpoint
41
+ raise 'Must be defined in subclasses!'
42
+ end
43
+
44
+ # Searches for FHIR resources using the provided parameters
45
+ # @param resource_type [String] The type of FHIR resource to search for
46
+ # (e.g. 'Organization', 'OrganizationAffiliation')
47
+ # @param params [Hash] Search parameters specific to the resource type
48
+ # @return [Hash] FHIR Bundle containing search results
49
+ # @example Search for organizations
50
+ # Client.search('Organization', lastUpdated: 'gt2024-01-01')
51
+ # @example Search for relationships
52
+ # Client.search('OrganizationAffiliation', organization: 'RHAGX')
53
+ def search(resource_type, params = {})
54
+ url = construct_url(endpoint, resource_type, params)
55
+
56
+ # Make the request
57
+ response = connection.get(url, headers)
58
+
59
+ # Process response
60
+ payload = JSON.parse(response.body)
61
+ raise_unless_response_success(response, payload)
62
+
63
+ payload
64
+ rescue JSON::ParserError
65
+ raise InvalidResponse, 'Invalid JSON response from server'
66
+ rescue StandardError => e
67
+ raise ApiError, "Search failed: #{e.message}"
68
+ end
69
+
70
+ private
71
+
72
+ def construct_url(endpoint, resource_type, params = {})
73
+ url = "#{endpoint}/#{resource_type}"
74
+
75
+ # Add query parameters if any exist
76
+ if params.any?
77
+ # Convert params to proper query string format
78
+ query_string = params.map do |key, value|
79
+ "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"
80
+ end.join('&')
81
+
82
+ url = "#{url}?#{query_string}"
83
+ end
84
+
85
+ url
86
+ end
87
+
88
+ # @return [ActiveResource::Connection] Configured connection instance
89
+ def connection
90
+ @connection ||= ActiveResource::Connection.new(endpoint)
91
+ end
92
+
93
+ # @return [Hash] FHIR API required headers
94
+ def headers
95
+ {
96
+ 'Accept' => 'application/fhir+json',
97
+ 'Content-Type' => 'application/fhir+json'
98
+ }.merge(additional_headers)
99
+ end
100
+
101
+ # Raises an error unless response is successful
102
+ def raise_unless_response_success(response, payload)
103
+ return if response.code == '200'
104
+
105
+ error_message = payload['errorText'] || 'Unknown error'
106
+ raise ApiError, "#{payload['errorCode']} - #{error_message}"
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,20 @@
1
+ require_relative '../base'
2
+
3
+ module NdrLookup
4
+ module Fhir
5
+ module Odt
6
+ # Client for interacting with the NHS Digital FHIR API
7
+ # See https://digital.nhs.uk/developer/api-catalogue/organisation-data-terminology
8
+ class Client < Base
9
+ FHIR_ODT_ENDPOINT = 'https://api.service.nhs.uk/organisation-data-terminology-api/fhir'.freeze
10
+
11
+ class << self
12
+ # Wrapped in method to enable stubbing in tests (constants are hard to stub)
13
+ def endpoint
14
+ FHIR_ODT_ENDPOINT
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,88 @@
1
+ require 'active_support'
2
+ require 'active_resource'
3
+ require_relative 'client'
4
+
5
+ module NdrLookup
6
+ module Fhir
7
+ module Odt
8
+ # Represents an Organization resource from the NHS Digital FHIR API
9
+ # The main endpoint path we are using is 'OrganiSation' but full API path calls also include 'OrganiZation'
10
+ class Organisation < Base
11
+ self.include_format_in_path = false
12
+ self.site = Client::FHIR_ODT_ENDPOINT
13
+ self.collection_name = 'Organization'
14
+
15
+ class << self
16
+ # Finds a specific organization by ID
17
+ # @return [Organisation] The found organization
18
+ def find(id)
19
+ response = Client.find('Organization', id)
20
+ new(response)
21
+ rescue Client::ResourceNotFound => e
22
+ logger.info("Organization not found: #{e.message}")
23
+ nil
24
+ end
25
+
26
+ # ActiveRecord subs .all in to /all which then becomes like finding the id 'all'
27
+ # so we have to explicitly block this if we want it to not be a thing.
28
+ def all
29
+ raise NotImplementedError, 'Use search method instead of all'
30
+ end
31
+
32
+ # Searches for organizations matching the provided parameters
33
+ # @return [Array<Organisation>] Matching organizations
34
+ def search(params = {})
35
+ response = Client.search('Organization', params)
36
+ return [] unless response['entry']
37
+
38
+ response['entry'].map { |entry| new(entry['resource']) }
39
+ rescue Client::ApiError => e
40
+ logger.info("Organization search failed: #{e.message}")
41
+ []
42
+ end
43
+
44
+ def sync(date)
45
+ formatted_date = case date
46
+ when Date, Time
47
+ date.strftime('%Y-%m-%d')
48
+ else
49
+ date.to_s
50
+ end
51
+
52
+ search_params = { _lastUpdated: "gt#{formatted_date}" }
53
+ search(search_params)
54
+ end
55
+
56
+ private
57
+
58
+ def logger
59
+ @logger ||= if defined?(Rails) && Rails.respond_to?(:logger)
60
+ Rails.logger
61
+ else
62
+ Logger.new($stdout)
63
+ end
64
+ end
65
+ end
66
+
67
+ # Initializes a new Organization with downcased attributes
68
+ def initialize(attributes = {})
69
+ attributes.deep_transform_keys!(&:downcase) if attributes.is_a?(Hash)
70
+ super
71
+ end
72
+
73
+ private
74
+
75
+ # Prevents Date constants from raising errors
76
+ # When ActiveResource tries to process something called 'Date'
77
+ # it would call const_valid?('Date', false).
78
+ # This would return false, making ActiveResource skip trying to create a Date constant and
79
+ # instead fall back to creating an :UnnamedResource.
80
+ def const_valid?(*const_args)
81
+ return false if const_args.first == 'Date'
82
+
83
+ super
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,83 @@
1
+ require 'active_support/core_ext/module'
2
+ require 'net/http'
3
+ require 'rubyntlm'
4
+ require 'httpi'
5
+ require 'json'
6
+ require_relative 'matched_record'
7
+
8
+ module NdrLookup
9
+ module LocatorHub
10
+ # This class is the LocatorHub API client
11
+ class Client
12
+ LIST_LOCATORS_PATH = 'listlocators?format=json'.freeze
13
+
14
+ LOCATOR_ID = 'LocatorId'.freeze
15
+ LOCATOR_NAME = 'LocatorName'.freeze
16
+
17
+ ADDRESS_BASE_PREMIUM_WA = 'AddressBasePremiumWA'.freeze
18
+
19
+ # The authenticating domain credentials of the API,
20
+ # all of which must be configured by the host app
21
+ cattr_accessor :domain, :password, :username
22
+ self.domain = nil
23
+ self.password = nil
24
+ self.username = nil
25
+
26
+ def initialize(api_path)
27
+ check_credentials
28
+ @api_path = api_path
29
+ end
30
+
31
+ def locator_id
32
+ @locator_id ||= locator[LOCATOR_ID]
33
+ end
34
+
35
+ def rectify_address(address)
36
+ query = URI.encode_www_form(
37
+ Query: address,
38
+ Fuzzy: 'false',
39
+ ReturnCoordinateSystem: '-1',
40
+ format: 'json'
41
+ )
42
+ response = call_postcode_service("Rectify/#{locator_id}/ADDRESS?#{query}")
43
+ MatchedRecord.new(response)
44
+ end
45
+
46
+ # def capabilities
47
+ # call_postcode_service("Capabilities/#{locator_id}?format=json")
48
+ # end
49
+
50
+ private
51
+
52
+ def listlocators
53
+ call_postcode_service(LIST_LOCATORS_PATH)
54
+ end
55
+
56
+ def locator
57
+ listlocators.find { |l| l[LOCATOR_NAME] == ADDRESS_BASE_PREMIUM_WA }
58
+ end
59
+
60
+ def request
61
+ @request ||= begin
62
+ request = HTTPI::Request.new
63
+ request.auth.ntlm(username, password, domain)
64
+ request
65
+ end
66
+ end
67
+
68
+ def call_postcode_service(path)
69
+ request.url = [@api_path, path].join('/')
70
+ response = HTTPI.get(request)
71
+ JSON.parse(response.body)
72
+ end
73
+
74
+ def check_credentials
75
+ unset_attributes = %i[domain password username].select { |attr| send(attr).nil? }
76
+ return if unset_attributes.empty?
77
+
78
+ raise "NdrLookup::LocatorHub::Client attributes #{unset_attributes.join(', ')} " \
79
+ 'have not been configured'
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,61 @@
1
+ module NdrLookup
2
+ module LocatorHub
3
+ # This class wraps the client response, creating a hash of matched record
4
+ # columns and provides custom getter methods for cleaning/casting specific columns
5
+ class MatchedRecord
6
+ RECTIFY_RECORD_SCORE = 'RectifyRecordScore'.freeze
7
+ MATCHED_RECORD = 'MatchedRecord'.freeze
8
+ COLUMNS = 'Columns'.freeze
9
+ N = 'N'.freeze
10
+ R = 'R'.freeze
11
+
12
+ LOCATOR_DESCRIPTION = 'LOCATOR_DESCRIPTION'.freeze
13
+ ADMINISTRATIVE_AREA = 'ADMINISTRATIVE_AREA'.freeze
14
+ POSTCODE = 'POSTCODE'.freeze
15
+ UDPRN = 'DPA_UDPRN'.freeze
16
+
17
+ attr_reader :score
18
+
19
+ def initialize(response)
20
+ @score = response[RECTIFY_RECORD_SCORE]
21
+ @record = response[MATCHED_RECORD]
22
+ end
23
+
24
+ def locator_description
25
+ @locator_description ||= columns[LOCATOR_DESCRIPTION].
26
+ gsub(/\|LOCATOR_SEPARATOR\|/, ', ')
27
+ end
28
+
29
+ def administrative_area
30
+ @administrative_area ||= columns[ADMINISTRATIVE_AREA]
31
+ end
32
+
33
+ def locator_description_with_administrative_area
34
+ return locator_description if locator_description.include?(administrative_area)
35
+
36
+ parts = locator_description.split(', ')
37
+ (parts[0..-2] << administrative_area << parts[-1]).join(', ')
38
+ end
39
+
40
+ def postcode
41
+ @postcode ||= columns[POSTCODE]
42
+ end
43
+
44
+ def udprn
45
+ @udprn ||= columns[UDPRN] == columns[UDPRN].to_i.to_s ? columns[UDPRN].to_i : columns[UDPRN]
46
+ end
47
+
48
+ # keys are returned in response['MatchedRecord']['Columns']['N'] and values are returned
49
+ # in response['MatchedRecord']['Columns']['R'] and we turn them back into a normal hash
50
+ def columns
51
+ @columns ||= begin
52
+ columns = {}
53
+ @record && @record[COLUMNS].each.with_index do |column, i|
54
+ columns[column[N]] = @record[R][i]
55
+ end
56
+ columns
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,219 @@
1
+ require_relative 'client'
2
+
3
+ module NdrLookup
4
+ module LocatorHub
5
+ # Thread queue
6
+ class Queue
7
+ BATCH_SIZE = 100
8
+
9
+ def initialize(api_path, klass, max_id, total_count)
10
+ @api_path = api_path
11
+ @klass = klass
12
+ @max_id = max_id
13
+ @total_count = total_count
14
+ @max_worker_count = 8
15
+ @worker_count = 1
16
+
17
+ queue_records
18
+
19
+ # @threads = [record_getter, max_rate_limiter, rate_limiter, record_setter, monitor]
20
+ @threads = [record_getter, record_setter]
21
+
22
+ t0 = Time.current
23
+ @max_worker_count.times { |i| @threads << worker(i) }
24
+
25
+ @threads.map(&:join)
26
+ Rails.logger.info "Total Time taken: #{Time.current - t0} secs"
27
+ Rails.logger.info "Time taken (per address): #{(Time.current - t0) / BATCH_SIZE} secs"
28
+ end
29
+
30
+ private
31
+
32
+ # Pretty form of address field.
33
+ def print_full_address(object)
34
+ "#{object.address&.gsub(/,/, ', ')&.titleize}, " \
35
+ "#{object.postcode}"
36
+ end
37
+
38
+ def input_queue
39
+ @input_queue ||= ::Queue.new
40
+ end
41
+
42
+ def output_queue
43
+ @output_queue ||= ::Queue.new
44
+ end
45
+
46
+ def worker(i)
47
+ looping_thread("worker #{i}", 120) do
48
+ connect_client do |locator_hub|
49
+ while (i + 1) > @worker_count
50
+ sleep 10
51
+ raise ThreadError if @worker_count.zero?
52
+ end
53
+ raise ThreadError if @worker_count.zero?
54
+
55
+ rec = input_queue.pop(true)
56
+ next if rec.address.blank? && rec.postcode.blank?
57
+ result = locator_hub.rectify_address(print_full_address(rec))
58
+ next unless result.postcode == rec.postcode
59
+
60
+ lcs_percent = lcs_commonality_percentage(print_full_address(rec),
61
+ result.locator_description)
62
+
63
+ if result.score >= 85 || (result.score >= 70 && lcs_percent >= 80)
64
+ rec.udprn = result.udprn if result.udprn.present?
65
+ next unless rec.changed? && rec.valid?
66
+
67
+ output_queue.push(rec)
68
+ else
69
+ # rubocop:disable Rails/Output
70
+ puts
71
+ puts "locator_description: #{result.locator_description.inspect}"
72
+ debug_scores(rec, result, lcs_percent)
73
+
74
+ lcs_percent2 = lcs_commonality_percentage(
75
+ print_full_address(rec),
76
+ result.locator_description_with_administrative_area
77
+ )
78
+ if lcs_percent2 != lcs_percent
79
+ puts 'locator_description_with_administrative_area: ' +
80
+ result.locator_description_with_administrative_area.inspect
81
+ debug_scores(rec, result, lcs_percent2)
82
+ # rubocop:enable Rails/Output
83
+ end
84
+ # require 'pry'; binding.pry
85
+ end
86
+ end # connect_client
87
+
88
+ Rails.logger.info 'Sleeping...'
89
+ end # looping_thread
90
+ end
91
+
92
+ def debug_scores(rec, result, lcs_percent)
93
+ # rubocop:disable Rails/Output
94
+ puts rec.id
95
+ puts print_full_address(rec)
96
+ puts "Match Score: #{result.score}"
97
+ puts "LCS: #{lcs_percent}%"
98
+ # rubocop:enable Rails/Output
99
+ end
100
+
101
+ # This method connects to LocatorHub, yields the connection and handles exceptions.
102
+ # It will supress everything except database and thread exceptions.
103
+ def connect_client
104
+ raise(ArgumentError, 'block required') unless block_given?
105
+
106
+ client = LocatorHub::Client.new(@api_path)
107
+ loop do
108
+ yield(client)
109
+ end
110
+ rescue OCIError, ThreadError => e
111
+ raise e
112
+ rescue StandardError => e
113
+ Rails.logger.info e.inspect
114
+ end
115
+
116
+ def looping_thread(name, sleep_duration)
117
+ Thread.new do
118
+ loop do
119
+ raise ThreadError if @worker_count.zero?
120
+
121
+ yield
122
+
123
+ sleep sleep_duration
124
+ end
125
+ rescue ThreadError
126
+ Rails.logger.info "#{name} thread finished!"
127
+ end
128
+ end
129
+
130
+ def monitor
131
+ @monitor ||= looping_thread('monitor', 1) do
132
+ Rails.logger.info "input_queue: #{input_queue.length} " \
133
+ "output_queue: #{output_queue.length}"
134
+ end
135
+ end
136
+
137
+ def record_getter
138
+ @record_getter ||= looping_thread('record_getter', 1) do
139
+ queue_records if input_queue.length < 25
140
+
141
+ raise ThreadError if input_queue.empty?
142
+ end
143
+ end
144
+
145
+ def record_setter
146
+ @record_setter ||= looping_thread('record_setter', 1) do
147
+ persist_records if output_queue.length > BATCH_SIZE
148
+
149
+ raise ThreadError if input_queue.empty? && output_queue.empty?
150
+ end
151
+ end
152
+
153
+ def weekday?
154
+ (1..5).cover?(Time.current.wday)
155
+ end
156
+
157
+ def max_rate_limiter
158
+ @max_rate_limiter ||= looping_thread('max_rate_limiter', 10) do
159
+ @max_worker_count = ask('Max Threads: ').to_i
160
+ end
161
+ end
162
+
163
+ def rate_limiter
164
+ @rate_limiter ||= looping_thread('rate_limiter', 60) do
165
+ @worker_count = weekday? && (8..15).cover?(Time.current.hour) ? 2 : 10
166
+ @worker_count = @max_worker_count if @max_worker_count < @worker_count
167
+ end
168
+ end
169
+
170
+ def lcs_commonality_percentage(a, b)
171
+ squashed_a = a.upcase.gsub(/[,\.\s]+/, '')
172
+ squashed_b = b.upcase.gsub(/[,\.\s]+/, '')
173
+ shorter_length = [squashed_a, squashed_b].map(&:length).min
174
+
175
+ lcs = Diff::LCS.lcs(squashed_a, squashed_b)
176
+ 100.0 * lcs.length / shorter_length
177
+ end
178
+
179
+ def queue_records
180
+ # Rails.logger.info "pushing #{BATCH_SIZE} records onto the queue"
181
+ records = get_records_below_id(@max_id, BATCH_SIZE)
182
+ records.each { |record| input_queue.push(record) }
183
+ end
184
+
185
+ def get_records_below_id(id, limit = BATCH_SIZE)
186
+ # find_in_batches ignores order
187
+ # records = Address.includes(:patient).where(udprn: nil).
188
+ records = @klass.where(udprn: nil).
189
+ where("#{@klass.primary_key} < ?", id).
190
+ order("#{@klass.primary_key} desc").
191
+ limit(limit)
192
+ @max_id = records.last.id
193
+ records
194
+ end
195
+
196
+ def persist_records
197
+ Rails.logger.info 'persist_records'
198
+ @klass.transaction do
199
+ change_count = 0
200
+
201
+ output_queue.length.times do
202
+ rec = output_queue.pop(true)
203
+
204
+ # # next unless rec.changed? && rec.valid?
205
+ # # Rails.logger.info "CHANGES: #{rec.changes.inspect}"
206
+ next unless rec.save
207
+ change_count += 1
208
+ @total_count += 1
209
+ end
210
+ # rubocop:disable Rails/Output
211
+ puts "Change count: #{change_count} (Total: #{@total_count})" \
212
+ " Last id: #{@max_id}"
213
+ puts "worker_count: #{@worker_count} max_worker_count: #{@max_worker_count}"
214
+ # rubocop:enable Rails/Output
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,48 @@
1
+ require 'active_resource'
2
+ require 'active_support'
3
+ require 'active_support/core_ext'
4
+ require 'httpi'
5
+ require 'json'
6
+
7
+ module NdrLookup
8
+ module NhsdOds
9
+ # The API Client for hitting the sync and search endpoints
10
+ class Client
11
+ ENDPOINT = 'https://directory.spineservices.nhs.uk/ORD/2-0-0/'.freeze
12
+
13
+ class << self
14
+ def sync(date)
15
+ date = date.to_date
16
+ raise ArgumentError, 'invalid date' unless date.is_a?(Date)
17
+
18
+ request = ENDPOINT + 'sync?LastChangeDate=' + date.strftime('%F')
19
+ response = HTTPI.get(request)
20
+ payload = JSON.parse(response.body)
21
+
22
+ raise_unless_response_success(response, payload)
23
+
24
+ payload['Organisations']
25
+ end
26
+
27
+ def search(params = {})
28
+ # Expecting the keys to be valid query parameters
29
+ # https://digital.nhs.uk/services/organisation-data-service/guidance-for-developers/search-endpoint#parameters
30
+ query = HTTPI::QueryBuilder::Flat.build(params)
31
+ request = ENDPOINT + 'organisations?' + query
32
+ response = HTTPI.get(request)
33
+ payload = JSON.parse(response.body)
34
+
35
+ raise_unless_response_success(response, payload)
36
+
37
+ payload['Organisations']
38
+ end
39
+
40
+ private
41
+
42
+ def raise_unless_response_success(response, payload)
43
+ raise "#{payload['errorCode']} - #{payload['errorText']}" unless response.code == 200
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ require 'active_support'
2
+ require 'active_resource'
3
+ require_relative 'client'
4
+
5
+ module NdrLookup
6
+ module NhsdOds
7
+ # Retrieve CodeSystems information for NHS Digital ODS meta data.
8
+ class CodeSystems < ActiveResource::Base
9
+ self.include_format_in_path = false
10
+ self.site = Client::ENDPOINT
11
+
12
+ def initialize(attributes, persisted = false)
13
+ attributes = attributes.first if attributes.is_a? Array
14
+ attributes.deep_transform_keys!(&:downcase)
15
+ super(attributes, persisted)
16
+ end
17
+ end
18
+
19
+ # Retrieve CodeSystems information for Record class meta data.
20
+ class Recordclass < CodeSystems
21
+ end
22
+
23
+ # Retrieve CodeSystems information for Relationships meta data.
24
+ class Rel < CodeSystems
25
+ end
26
+
27
+ # Retrieve CodeSystems information for Roles meta data.
28
+ class Role < CodeSystems
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ require 'active_support'
2
+ require 'active_resource'
3
+ require_relative 'client'
4
+
5
+ module NdrLookup
6
+ module NhsdOds
7
+ # The orgaisation class to allow organisation to work like you would expect a model to work
8
+ # The API only supports .find <code>
9
+ class Organisation < ActiveResource::Base
10
+ self.include_format_in_path = false
11
+ self.site = Client::ENDPOINT
12
+
13
+ def initialize(attributes, persisted = false)
14
+ attributes.deep_transform_keys!(&:downcase)
15
+ super(attributes, persisted)
16
+ end
17
+
18
+ private
19
+
20
+ def const_valid?(*const_args)
21
+ return false if const_args.first == 'Date'
22
+
23
+ super(*const_args)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1 @@
1
+ load 'tasks/locator_hub.rake'
@@ -0,0 +1,3 @@
1
+ module NdrLookup
2
+ VERSION = '0.2.0'.freeze
3
+ end
data/lib/ndr_lookup.rb ADDED
@@ -0,0 +1,14 @@
1
+ require 'ndr_lookup/locator_hub/matched_record'
2
+ require 'ndr_lookup/locator_hub/client'
3
+ require 'ndr_lookup/locator_hub/queue'
4
+ require 'ndr_lookup/nhsd_ods/code_systems'
5
+ require 'ndr_lookup/nhsd_ods/client'
6
+ require 'ndr_lookup/nhsd_ods/organisation'
7
+ require 'ndr_lookup/version'
8
+ require 'ndr_lookup/fhir/odt/client'
9
+ require 'ndr_lookup/fhir/odt/organisation'
10
+
11
+ module NdrLookup
12
+ class Error < StandardError; end
13
+ # Your code goes here...
14
+ end
@@ -0,0 +1,37 @@
1
+ namespace :locator_hub do
2
+ desc 'setup'
3
+ task :setup do
4
+ require 'highline/import'
5
+ require 'ndr_lookup/locator_hub/client'
6
+
7
+ NdrLookup::LocatorHub::Client.domain = ask('Domain name: ')
8
+ NdrLookup::LocatorHub::Client.username = ask('Domain Username: ')
9
+ NdrLookup::LocatorHub::Client.password = ask('Domain Password: ') { |q| q.echo = false }
10
+
11
+ @api_path = ask('API Path: ')
12
+ end
13
+
14
+ desc 'locator'
15
+ task locator: :setup do
16
+ HTTPI.log = false
17
+ # HTTPI.log_level = :warn
18
+
19
+ klass = Address
20
+ max_id = klass.where('udprn is not null').maximum(:id)
21
+ puts max_id
22
+ total_count = klass.where('udprn is not null').count
23
+
24
+ LocatorHub::Queue.new(@api_path, klass, max_id, total_count)
25
+ end
26
+
27
+ desc 'rectify_address'
28
+ task rectify_address: :setup do
29
+ # Usage: bundle exec rake locator_hub:rectify_address
30
+
31
+ address = ask('Address: ')
32
+
33
+ client = NdrLookup::LocatorHub::Client.new(@api_path)
34
+ matched_record = client.rectify_address(address)
35
+ puts matched_record.inspect
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,204 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ndr_lookup
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - NCRS Development Team
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-09-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activeresource
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '7'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '6.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '7'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activesupport
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '8.1'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '7.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '8.1'
53
+ - !ruby/object:Gem::Dependency
54
+ name: httpi
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '4.0'
60
+ type: :runtime
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '4.0'
67
+ - !ruby/object:Gem::Dependency
68
+ name: rubyntlm
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '0.6'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '0.6'
81
+ - !ruby/object:Gem::Dependency
82
+ name: bundler
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '2.0'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '2.0'
95
+ - !ruby/object:Gem::Dependency
96
+ name: minitest
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '5.0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '5.0'
109
+ - !ruby/object:Gem::Dependency
110
+ name: ndr_dev_support
111
+ requirement: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '6.0'
116
+ - - "<"
117
+ - !ruby/object:Gem::Version
118
+ version: '8.0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '6.0'
126
+ - - "<"
127
+ - !ruby/object:Gem::Version
128
+ version: '8.0'
129
+ - !ruby/object:Gem::Dependency
130
+ name: rake
131
+ requirement: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 12.3.3
136
+ type: :development
137
+ prerelease: false
138
+ version_requirements: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: 12.3.3
143
+ - !ruby/object:Gem::Dependency
144
+ name: webmock
145
+ requirement: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ type: :development
151
+ prerelease: false
152
+ version_requirements: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ description: NDR library to consume lookup data APIs
158
+ email: []
159
+ executables: []
160
+ extensions: []
161
+ extra_rdoc_files: []
162
+ files:
163
+ - CHANGELOG.md
164
+ - CODE_OF_CONDUCT.md
165
+ - LICENSE.txt
166
+ - README.md
167
+ - Rakefile
168
+ - lib/ndr_lookup.rb
169
+ - lib/ndr_lookup/fhir/base.rb
170
+ - lib/ndr_lookup/fhir/odt/client.rb
171
+ - lib/ndr_lookup/fhir/odt/organisation.rb
172
+ - lib/ndr_lookup/locator_hub/client.rb
173
+ - lib/ndr_lookup/locator_hub/matched_record.rb
174
+ - lib/ndr_lookup/locator_hub/queue.rb
175
+ - lib/ndr_lookup/nhsd_ods/client.rb
176
+ - lib/ndr_lookup/nhsd_ods/code_systems.rb
177
+ - lib/ndr_lookup/nhsd_ods/organisation.rb
178
+ - lib/ndr_lookup/tasks.rb
179
+ - lib/ndr_lookup/version.rb
180
+ - lib/tasks/locator_hub.rake
181
+ homepage: https://github.com/NHSDigital/ndr_lookup
182
+ licenses:
183
+ - MIT
184
+ metadata: {}
185
+ post_install_message:
186
+ rdoc_options: []
187
+ require_paths:
188
+ - lib
189
+ required_ruby_version: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - ">="
192
+ - !ruby/object:Gem::Version
193
+ version: '3.0'
194
+ required_rubygems_version: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - ">="
197
+ - !ruby/object:Gem::Version
198
+ version: '0'
199
+ requirements: []
200
+ rubygems_version: 3.4.19
201
+ signing_key:
202
+ specification_version: 4
203
+ summary: NDR Lookup library
204
+ test_files: []