- Step 3:
+ Step 3:
Select identifier type
- Change field
+ Change field
[[identifier_type]]
@@ -91,10 +97,10 @@
@@ -103,7 +109,7 @@
- Step 3:
+ Step 3:
Select data to add
@@ -111,32 +117,33 @@
@@ -145,18 +152,18 @@
Your file will not leave your computer, but the organsiation identifiers from
- the column you select will be sent to the findthatcharity server in order to
+ the column you select will be sent to the findthatcharity server in order to
retrieve the information. No other data is sent to the server.
It could therefore be possible to reconstruct the organisations contained
in your file. You should think carefully before using this tool
- for any personal or sensitive information.
+ for any personal or sensitive information.
{% endblock %}
{% block bodyscripts %}
{% endblock %}
\ No newline at end of file
diff --git a/addtocsv/static/js/csv.js b/addtocsv/static/js/csv.js
index 9b56a95a..fe99200d 100644
--- a/addtocsv/static/js/csv.js
+++ b/addtocsv/static/js/csv.js
@@ -50,6 +50,14 @@ function openSaveFileDialog(data, filename, mimetype) {
}
+function extractProperties(orgRecord) {
+ // properties are stored as a list of dicts, with a single key/value pair
+ // // We don't neeed to extract the key, so we just return the value
+ return Object.fromEntries(Object.entries(orgRecord).map(
+ ([k, v]) => [k, Object.values(v).map((v) => Object.values(v)).join("; ")]
+ ));
+}
+
var app = new Vue({
el: '#addtocsv',
data: {
@@ -166,6 +174,11 @@ var app = new Vue({
})
}))
.then(data => Object.assign({}, ...data))
+ .then(data => Object.fromEntries(Object.entries(data).map(
+ ([orgid, properties]) => (
+ [orgid, extractProperties(properties)]
+ )
+ )))
.then(new_data => openSaveFileDialog(
Papa.unparse({
fields: component.csv_results.meta.fields.concat(component.fields_to_add),
diff --git a/charity/api/charities.py b/charity/api/charities.py
index 0ac5456a..54ed672f 100644
--- a/charity/api/charities.py
+++ b/charity/api/charities.py
@@ -1,4 +1,6 @@
-from datetime import date
+from datetime import date as Date
+from datetime import timedelta
+from typing import Optional
from django.http import Http404
from django.shortcuts import get_list_or_404, get_object_or_404
@@ -14,16 +16,16 @@
class CharityResult(Schema):
success: bool = True
- error: str = None
+ error: Optional[str] = None
params: dict = {}
- result: CharityOut = None
+ result: Optional[CharityOut] = None
class CharityFinancialResult(Schema):
success: bool = True
- error: str = None
+ error: Optional[str] = None
params: dict = {}
- result: CharityFinancialOut = None
+ result: Optional[CharityFinancialOut] = None
api = Router(tags=["Charities"])
@@ -78,23 +80,44 @@ def get_charity_finance_latest(request, charity_id: str):
"/{charity_id}/financial/{date}",
response={200: CharityFinancialResult, 404: ResultError},
)
-def get_charity_finance_by_date(request, charity_id: str, date: date):
+def get_charity_finance_by_date(request, charity_id: str, date: Date):
charity_id = regno_to_orgid(charity_id)
try:
charity = get_object_or_404(Charity, id=charity_id)
- financial_years = get_list_or_404(
- CharityFinancial,
- charity=charity,
+
+ # try using financial year start date
+ financial_year = charity.financial.filter(
fyend__gte=date,
fystart__lte=date,
- )
+ ).first()
+
+ # financial year end exact match
+ if not financial_year:
+ financial_year = charity.financial.filter(
+ fyend=date,
+ ).first()
+
+ # financial year end is after or equal to the date
+ if not financial_year:
+ next_year = date + timedelta(days=365)
+ financial_year = (
+ charity.financial.filter(
+ fyend__gte=date,
+ fyend__lt=next_year,
+ )
+ .order_by("fyend")
+ .first()
+ )
+
+ if not financial_year:
+ raise Http404("No CharityFinancial matches the given query.")
return {
"error": None,
"params": {
"charity_id": charity_id,
"date": date,
},
- "result": financial_years[0],
+ "result": financial_year,
}
except Http404 as e:
return 404, {
diff --git a/charity/api/schema.py b/charity/api/schema.py
index 7d3efde5..55a67bbd 100644
--- a/charity/api/schema.py
+++ b/charity/api/schema.py
@@ -1,78 +1,79 @@
from datetime import date, datetime
+from typing import Optional
from ninja import Schema
class Charity(Schema):
- id: str = None
- name: str = None
- constitution: str = None
- geographical_spread: str = None
- address: str = None
- postcode: str = None
- phone: str = None
- active: bool = None
- date_registered: date = None
- date_removed: date = None
- removal_reason: str = None
- web: str = None
- email: str = None
- company_number: str = None
- activities: str = None
- source: str = None
- first_added: datetime = None
- last_updated: datetime = None
- income: int = None
- spending: int = None
- latest_fye: date = None
- employees: int = None
- volunteers: int = None
- trustees: int = None
- dual_registered: bool = None
+ id: Optional[str] = None
+ name: Optional[str] = None
+ constitution: Optional[str] = None
+ geographical_spread: Optional[str] = None
+ address: Optional[str] = None
+ postcode: Optional[str] = None
+ phone: Optional[str] = None
+ active: Optional[bool] = None
+ date_registered: Optional[date] = None
+ date_removed: Optional[date] = None
+ removal_reason: Optional[str] = None
+ web: Optional[str] = None
+ email: Optional[str] = None
+ company_number: Optional[str] = None
+ activities: Optional[str] = None
+ source: Optional[str] = None
+ first_added: Optional[datetime] = None
+ last_updated: Optional[datetime] = None
+ income: Optional[int] = None
+ spending: Optional[int] = None
+ latest_fye: Optional[date] = None
+ employees: Optional[int] = None
+ volunteers: Optional[int] = None
+ trustees: Optional[int] = None
+ dual_registered: Optional[bool] = None
class CharityFinancial(Schema):
- fyend: date = None
- fystart: date = None
- income: int = None
- spending: int = None
- inc_leg: int = None
- inc_end: int = None
- inc_vol: int = None
- inc_fr: int = None
- inc_char: int = None
- inc_invest: int = None
- inc_other: int = None
- inc_total: int = None
- invest_gain: int = None
- asset_gain: int = None
- pension_gain: int = None
- exp_vol: int = None
- exp_trade: int = None
- exp_invest: int = None
- exp_grant: int = None
- exp_charble: int = None
- exp_gov: int = None
- exp_other: int = None
- exp_total: int = None
- exp_support: int = None
- exp_dep: int = None
- reserves: int = None
- asset_open: int = None
- asset_close: int = None
- fixed_assets: int = None
- open_assets: int = None
- invest_assets: int = None
- cash_assets: int = None
- current_assets: int = None
- credit_1: int = None
- credit_long: int = None
- pension_assets: int = None
- total_assets: int = None
- funds_end: int = None
- funds_restrict: int = None
- funds_unrestrict: int = None
- funds_total: int = None
- employees: int = None
- volunteers: int = None
- account_type: str = None
+ fyend: Optional[date] = None
+ fystart: Optional[date] = None
+ income: Optional[int] = None
+ spending: Optional[int] = None
+ inc_leg: Optional[int] = None
+ inc_end: Optional[int] = None
+ inc_vol: Optional[int] = None
+ inc_fr: Optional[int] = None
+ inc_char: Optional[int] = None
+ inc_invest: Optional[int] = None
+ inc_other: Optional[int] = None
+ inc_total: Optional[int] = None
+ invest_gain: Optional[int] = None
+ asset_gain: Optional[int] = None
+ pension_gain: Optional[int] = None
+ exp_vol: Optional[int] = None
+ exp_trade: Optional[int] = None
+ exp_invest: Optional[int] = None
+ exp_grant: Optional[int] = None
+ exp_charble: Optional[int] = None
+ exp_gov: Optional[int] = None
+ exp_other: Optional[int] = None
+ exp_total: Optional[int] = None
+ exp_support: Optional[int] = None
+ exp_dep: Optional[int] = None
+ reserves: Optional[int] = None
+ asset_open: Optional[int] = None
+ asset_close: Optional[int] = None
+ fixed_assets: Optional[int] = None
+ open_assets: Optional[int] = None
+ invest_assets: Optional[int] = None
+ cash_assets: Optional[int] = None
+ current_assets: Optional[int] = None
+ credit_1: Optional[int] = None
+ credit_long: Optional[int] = None
+ pension_assets: Optional[int] = None
+ total_assets: Optional[int] = None
+ funds_end: Optional[int] = None
+ funds_restrict: Optional[int] = None
+ funds_unrestrict: Optional[int] = None
+ funds_total: Optional[int] = None
+ employees: Optional[int] = None
+ volunteers: Optional[int] = None
+ account_type: Optional[str] = None
diff --git a/charity/tests/__init__.py b/charity/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/charity/tests/test_charity_api.py b/charity/tests/test_charity_api.py
new file mode 100644
index 00000000..a3f36a6f
--- /dev/null
+++ b/charity/tests/test_charity_api.py
@@ -0,0 +1,86 @@
+import logging
+
+from charity.models import Charity
+from ftc.tests import TestCase
+
+logger = logging.getLogger(__name__)
+
+
+class TestCharityAPI(TestCase):
+ def setUp(self):
+ super().setUp()
+ Charity.objects.create(
+ id="GB-CHC-1234567",
+ name="Test charity",
+ active=True,
+ scrape=self.scrape,
+ )
+ charity2 = Charity.objects.create(
+ id="GB-CHC-2345678",
+ name="Test charity 2",
+ active=True,
+ scrape=self.scrape,
+ )
+ charity2.financial.create(
+ income=123456,
+ spending=123456,
+ fyend="2020-03-31",
+ )
+ charity2.financial.create(
+ income=654321,
+ spending=123456,
+ fyend="2019-03-31",
+ )
+
+ # GET request to /api/v1/companies/__id__ should return an organisation
+ def test_get_charity(self):
+ response = self.client.get("/api/v1/charities/1234567")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"]["name"], "Test charity")
+
+ def test_get_charity_missing(self):
+ response = self.client.get("/api/v1/charities/BLAHBLAH")
+ self.assertEqual(response.status_code, 404)
+ data = response.json()
+ self.assertEqual(data["error"], "No Charity matches the given query.")
+
+ def test_get_charity_financial_latest(self):
+ response = self.client.get("/api/v1/charities/2345678/financial/latest")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"]["income"], 123456)
+ self.assertEqual(data["result"]["fyend"], "2020-03-31")
+
+ def test_get_charity_financial_date(self):
+ response = self.client.get("/api/v1/charities/2345678/financial/2019-03-31")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"]["income"], 654321)
+ self.assertEqual(data["result"]["fyend"], "2019-03-31")
+
+ def test_get_charity_financial_missing(self):
+ urls = [
+ "/api/v1/charities/2345678/financial/2018-03-31",
+ "/api/v1/charities/2345678/financial/2020-05-15",
+ "/api/v1/charities/1234567/financial/latest",
+ "/api/v1/charities/1234567/financial/2018-03-31",
+ "/api/v1/charities/1234567/financial/2020-05-15",
+ "/api/v1/charities/1234567/financial/2020-03-31",
+ "/api/v1/charities/blahblah/financial/latest",
+ "/api/v1/charities/blahblah/financial/2018-03-31",
+ "/api/v1/charities/blahblah/financial/2020-05-15",
+ "/api/v1/charities/blahblah/financial/2020-03-31",
+ ]
+ for url in urls:
+ with self.subTest(url):
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+ data = response.json()
+ self.assertTrue(
+ data["error"]
+ in [
+ "No CharityFinancial matches the given query.",
+ "No Charity matches the given query.",
+ ]
+ )
diff --git a/charity/tests.py b/charity/tests/test_charity_view.py
similarity index 100%
rename from charity/tests.py
rename to charity/tests/test_charity_view.py
diff --git a/findthatcharity/endpoints.py b/findthatcharity/endpoints.py
index fad6e563..c22d9fa1 100644
--- a/findthatcharity/endpoints.py
+++ b/findthatcharity/endpoints.py
@@ -1,4 +1,4 @@
-from ninja import NinjaAPI
+from ninja import NinjaAPI, Swagger
from charity.api import api as charity_api
from ftc.api import company_api, organisation_api
@@ -8,6 +8,45 @@
title="Find that Charity API",
description="Search for information about charities and other non-profit organisations",
version="1.0",
+ docs=Swagger(
+ settings={
+ "defaultModelsExpandDepth": 0,
+ }
+ ),
+ openapi_extra={
+ "tags": [
+ {
+ "name": "Organisations",
+ },
+ {
+ "name": "Charities",
+ },
+ {
+ "name": "Companies",
+ },
+ {
+ "name": "Reconciliation (against all organisations)",
+ "externalDocs": {
+ "description": "Reconciliation Service API v0.2",
+ "url": "https://reconciliation-api.github.io/specs/latest/",
+ },
+ },
+ {
+ "name": "Reconciliation (against registered companies)",
+ "externalDocs": {
+ "description": "Reconciliation Service API v0.2",
+ "url": "https://reconciliation-api.github.io/specs/latest/",
+ },
+ },
+ {
+ "name": "Reconciliation (against specific type of organisation)",
+ "externalDocs": {
+ "description": "Reconciliation Service API v0.2",
+ "url": "https://reconciliation-api.github.io/specs/latest/",
+ },
+ },
+ ]
+ },
)
api.add_router("/organisations", organisation_api)
api.add_router("/charities", charity_api)
diff --git a/findthatcharity/settings.py b/findthatcharity/settings.py
index 263a131f..7d66c9b1 100644
--- a/findthatcharity/settings.py
+++ b/findthatcharity/settings.py
@@ -223,8 +223,6 @@
USE_I18N = True
-USE_L10N = True
-
USE_TZ = True
@@ -234,7 +232,17 @@
STATICFILES_DIRS = (os.path.join(BASE_DIR, "findthatcharity", "static"),)
STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATIC_URL = "/static/"
-STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
+
+
+STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
+ },
+}
+
WHITENOISE_MANIFEST_STRICT = True
LOGGING = {
@@ -307,7 +315,6 @@
ACCOUNT_ACTIVATION_DAYS = 7
REGISTRATION_OPEN = False
MAX_API_KEYS = 4 # Maximum number of API keys a user can have
-NINJA_DOCS_VIEW = "swagger"
TWITTER_CONSUMER_KEY = os.environ.get("TWITTER_CONSUMER_KEY")
TWITTER_CONSUMER_SECRET = os.environ.get("TWITTER_CONSUMER_SECRET")
diff --git a/findthatcharity/static/js/autocomplete.js b/findthatcharity/static/js/autocomplete.js
index af0f406e..2d3d335c 100644
--- a/findthatcharity/static/js/autocomplete.js
+++ b/findthatcharity/static/js/autocomplete.js
@@ -70,13 +70,13 @@ const updateAutoComplete = (results, el, q) => {
let a = createEl('a', {
classList: 'link dark-blue underline-hover pointer',
});
- a.setAttribute('href', r.url);
+ a.setAttribute('href', ORG_ID_URL.replace("__id__", r.id));
a.append(
getHighlightedText(r.name, q)
);
- if(r.orgtypes){
+ if (r.notable) {
let orgtype = createEl('span', { classList: 'fr gray i ml1 dib' });
- orgtype.innerText = ORGTYPES[r.orgtypes[0]];
+ orgtype.innerText = ORGTYPES[r.notable[0]];
a.append(orgtype);
}
li.append(a);
@@ -97,11 +97,11 @@ const fetchAutocomplete = () => {
updateAutoComplete([], resultsContainer, v);
return;
}
- const url = new URL(AUTOCOMPLETE_URL);
- url.searchParams.append('prefix', v);
+ var url = new URL(AUTOCOMPLETE_URL);
if (orgtype) {
- url.searchParams.append('orgtype', orgtype);
+ var url = new URL(AUTOCOMPLETE_ORGTYPE_URL.replace("__orgtype__", orgtype));
}
+ url.searchParams.append('prefix', v);
fetch(url)
.then((r) => {
var url = new URL(r.url);
diff --git a/ftc/api/companies.py b/ftc/api/companies.py
index e17cbc5d..34a5a7ec 100644
--- a/ftc/api/companies.py
+++ b/ftc/api/companies.py
@@ -1,5 +1,7 @@
+from typing import Optional
+
from charity_django.companies.models import Company
-from django.shortcuts import Http404
+from django.shortcuts import Http404, get_object_or_404
from ninja import Router, Schema
from ftc.api.organisations import ResultError
@@ -8,9 +10,9 @@
class CompanyResult(Schema):
success: bool = True
- error: str = None
+ error: Optional[str] = None
params: dict = {}
- result: CompanyOut = None
+ result: Optional[CompanyOut] = None
api = Router(tags=["Companies"])
@@ -27,7 +29,7 @@ def get_company(request, company_number: str):
"params": {
"company_number": company_number,
},
- "result": Company.objects.get(CompanyNumber=company_number),
+ "result": get_object_or_404(Company, CompanyNumber=company_number),
}
except Http404 as e:
return 404, {
diff --git a/ftc/api/organisations.py b/ftc/api/organisations.py
index 526f8a6e..44b1d873 100644
--- a/ftc/api/organisations.py
+++ b/ftc/api/organisations.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Optional
from django.core.paginator import Paginator
from django.shortcuts import Http404, get_object_or_404
@@ -17,31 +17,31 @@
class ResultError(Schema):
success: bool = False
- error: str = None
+ error: Optional[str] = None
params: dict = {}
- result: List = None
+ result: Optional[List] = None
class OrganisationResult(Schema):
success: bool = True
- error: str = None
+ error: Optional[str] = None
params: dict = {}
result: OrganisationOut
class OrganisationResultList(Schema):
success: bool = True
- error: str = None
+ error: Optional[str] = None
params: dict = {}
count: int = 0
- next: str = None
- previous: str = None
+ next: Optional[str] = None
+ previous: Optional[str] = None
result: List[OrganisationOut]
class SourceResult(Schema):
success: bool = True
- error: str = None
+ error: Optional[str] = None
params: dict = {}
result: SourceOut
@@ -61,19 +61,23 @@ def get_organisation_list(request, filters: OrganisationIn = Query({})):
queryset=Organisation.objects.prefetch_related("organisationTypePrimary"),
request=request,
)
- paginator = Paginator(f.qs, filters["limit"])
+ paginator = Paginator(f.qs.order_by("name"), filters["limit"])
response = paginator.page(filters["page"])
return {
"error": None,
"params": filters,
"count": paginator.count,
"result": list(response.object_list),
- "next": url_replace(request, page=response.next_page_number())
- if response.has_next()
- else None,
- "previous": url_replace(request, page=response.previous_page_number())
- if response.has_previous()
- else None,
+ "next": (
+ url_replace(request, page=response.next_page_number())
+ if response.has_next()
+ else None
+ ),
+ "previous": (
+ url_replace(request, page=response.previous_page_number())
+ if response.has_previous()
+ else None
+ ),
}
diff --git a/ftc/api/schema.py b/ftc/api/schema.py
index fbd381ac..acd47f4b 100644
--- a/ftc/api/schema.py
+++ b/ftc/api/schema.py
@@ -1,7 +1,7 @@
import datetime
-from typing import List
+from typing import List, Optional
-from ninja import Schema
+from ninja import Field, Schema
class OrganisationType(Schema):
@@ -13,131 +13,131 @@ class Source(Schema):
id: str
title: str
publisher: str
- data: dict = None
+ data: Optional[dict] = None
class OrgIdScheme(Schema):
code: str
data: dict
- priority: int = None
+ priority: Optional[int] = None
class Address(Schema):
- streetAddress: str = None
- addressLocality: str = None
- addressRegion: str = None
- addressCountry: str = None
- postalCode: str = None
+ streetAddress: Optional[str] = None
+ addressLocality: Optional[str] = None
+ addressRegion: Optional[str] = None
+ addressCountry: Optional[str] = None
+ postalCode: Optional[str] = None
class OrganisationLink(Schema):
- orgid: str = None
- url: str = None
+ orgid: Optional[str] = None
+ url: Optional[str] = None
class OrganisationWebsiteLink(Schema):
- site: str = None
- orgid: str = None
- url: str = None
+ site: Optional[str] = None
+ orgid: Optional[str] = None
+ url: Optional[str] = None
class Location(Schema):
- id: str = None
- name: str = None
- geocode: str = None
- type: str = None
+ id: Optional[str] = None
+ name: Optional[str] = None
+ geocode: Optional[str] = None
+ type: Optional[str] = None
class Organisation(Schema):
- id: str
+ id: str = Field(alias="org_id")
name: str
- charityNumber: str = None
- companyNumber: str = None
- description: str = None
- url: str = None
+ charityNumber: Optional[str] = None
+ companyNumber: Optional[str] = None
+ description: Optional[str] = None
+ url: Optional[str] = None
# finances
- latestFinancialYearEnd: datetime.date = None
- latestIncome: int = None
- latestSpending: int = None
- latestEmployees: int = None
- latestVolunteers: int = None
- trusteeCount: int = None
-
- dateRegistered: datetime.date = None
- dateRemoved: datetime.date = None
- active: bool = None
-
- parent: str = None
- organisationType: List[str] = None
- organisationTypePrimary: OrganisationType = None
- alternateName: List[str] = None
- telephone: str = None
- email: str = None
-
- location: List[dict] = None
- address: Address = None
-
- sources: List[str] = None
+ latestFinancialYearEnd: Optional[datetime.date] = None
+ latestIncome: Optional[int] = None
+ latestSpending: Optional[int] = None
+ latestEmployees: Optional[int] = None
+ latestVolunteers: Optional[int] = None
+ trusteeCount: Optional[int] = None
+
+ dateRegistered: Optional[datetime.date] = None
+ dateRemoved: Optional[datetime.date] = None
+ active: Optional[bool] = None
+
+ parent: Optional[str] = None
+ organisationType: Optional[List[str]] = None
+ organisationTypePrimary: Optional[OrganisationType] = None
+ alternateName: Optional[List[str]] = None
+ telephone: Optional[str] = None
+ email: Optional[str] = None
+
+ location: Optional[List[dict]] = None
+ address: Optional[Address] = None
+
+ sources: Optional[List[str]] = None
links: List[OrganisationWebsiteLink] = None
- orgIDs: List[str] = None
+ orgIDs: Optional[List[str]] = None
linked_records: List[OrganisationLink] = None
- dateModified: datetime.datetime = None
+ dateModified: Optional[datetime.datetime] = None
- # domain: str = None
- # status: str = None
+ # domain: Optional[str] = None
+ # status: Optional[str] = None
# source_id: str
- # # scrape: str = None
- # spider: str = None
- # # org_id_scheme: OrgIdScheme = None
+ # # scrape: Optional[str] = None
+ # spider: Optional[str] = None
+ # # org_id_scheme: Optional[OrgIdScheme] = None
# # geography fields
- # geo_oa11: str = None
- # geo_cty: str = None
- # geo_laua: str = None
- # geo_ward: str = None
- # geo_ctry: str = None
- # geo_rgn: str = None
- # geo_pcon: str = None
- # geo_ttwa: str = None
- # geo_lsoa11: str = None
- # geo_msoa11: str = None
- # geo_lep1: str = None
- # geo_lep2: str = None
- # geo_lat: float = None
- # geo_long: float = None
+ # geo_oa11: Optional[str] = None
+ # geo_cty: Optional[str] = None
+ # geo_laua: Optional[str] = None
+ # geo_ward: Optional[str] = None
+ # geo_ctry: Optional[str] = None
+ # geo_rgn: Optional[str] = None
+ # geo_pcon: Optional[str] = None
+ # geo_ttwa: Optional[str] = None
+ # geo_lsoa11: Optional[str] = None
+ # geo_msoa11: Optional[str] = None
+ # geo_lep1: Optional[str] = None
+ # geo_lep2: Optional[str] = None
+ # geo_lat: Optional[float] = None
+ # geo_long: Optional[float] = None
class Company(Schema):
- CompanyName: str = None
- CompanyNumber: str = None
- RegAddress_CareOf: str = None
- RegAddress_POBox: str = None
- RegAddress_AddressLine1: str = None
- RegAddress_AddressLine2: str = None
- RegAddress_PostTown: str = None
- RegAddress_County: str = None
- RegAddress_Country: str = None
- RegAddress_PostCode: str = None
- CompanyCategory: str = None
- CompanyStatus: str = None
- CountryOfOrigin: str = None
- DissolutionDate: datetime.date = None
- IncorporationDate: datetime.date = None
- Accounts_AccountRefDay: int = None
- Accounts_AccountRefMonth: int = None
- Accounts_NextDueDate: datetime.date = None
- Accounts_LastMadeUpDate: datetime.date = None
- Accounts_AccountCategory: str = None
- Returns_NextDueDate: datetime.date = None
- Returns_LastMadeUpDate: datetime.date = None
- Mortgages_NumMortCharges: int = None
- Mortgages_NumMortOutstanding: int = None
- Mortgages_NumMortPartSatisfied: int = None
- Mortgages_NumMortSatisfied: int = None
- LimitedPartnerships_NumGenPartners: int = None
- LimitedPartnerships_NumLimPartners: int = None
- ConfStmtNextDueDate: datetime.date = None
- ConfStmtLastMadeUpDate: datetime.date = None
- org_id: str = None
+ CompanyName: Optional[str] = None
+ CompanyNumber: Optional[str] = None
+ RegAddress_CareOf: Optional[str] = None
+ RegAddress_POBox: Optional[str] = None
+ RegAddress_AddressLine1: Optional[str] = None
+ RegAddress_AddressLine2: Optional[str] = None
+ RegAddress_PostTown: Optional[str] = None
+ RegAddress_County: Optional[str] = None
+ RegAddress_Country: Optional[str] = None
+ RegAddress_PostCode: Optional[str] = None
+ CompanyCategory: Optional[str] = None
+ CompanyStatus: Optional[str] = None
+ CountryOfOrigin: Optional[str] = None
+ DissolutionDate: Optional[datetime.date] = None
+ IncorporationDate: Optional[datetime.date] = None
+ Accounts_AccountRefDay: Optional[int] = None
+ Accounts_AccountRefMonth: Optional[int] = None
+ Accounts_NextDueDate: Optional[datetime.date] = None
+ Accounts_LastMadeUpDate: Optional[datetime.date] = None
+ Accounts_AccountCategory: Optional[str] = None
+ Returns_NextDueDate: Optional[datetime.date] = None
+ Returns_LastMadeUpDate: Optional[datetime.date] = None
+ Mortgages_NumMortCharges: Optional[int] = None
+ Mortgages_NumMortOutstanding: Optional[int] = None
+ Mortgages_NumMortPartSatisfied: Optional[int] = None
+ Mortgages_NumMortSatisfied: Optional[int] = None
+ LimitedPartnerships_NumGenPartners: Optional[int] = None
+ LimitedPartnerships_NumLimPartners: Optional[int] = None
+ ConfStmtNextDueDate: Optional[datetime.date] = None
+ ConfStmtLastMadeUpDate: Optional[datetime.date] = None
+ org_id: Optional[str] = None
diff --git a/ftc/documents.py b/ftc/documents.py
index 6096e38f..437fb4bc 100644
--- a/ftc/documents.py
+++ b/ftc/documents.py
@@ -262,6 +262,15 @@ class CompanyDocument(Document):
CompanyCategory = fields.KeywordField(attr="CompanyCategory")
PreviousNames = fields.TextField()
+ @classmethod
+ def search(cls, using=None, index=None):
+ return SearchWithTemplate(
+ using=cls._get_using(using),
+ index=cls._default_index(index),
+ doc_type=[cls],
+ model=cls.django.model,
+ )
+
def bulk(self, actions, **kwargs):
if self.django.queryset_pagination and "chunk_size" not in kwargs:
kwargs["chunk_size"] = self.django.queryset_pagination
diff --git a/ftc/jinja2/index.html.j2 b/ftc/jinja2/index.html.j2
index 25ebeda8..18c64e7f 100644
--- a/ftc/jinja2/index.html.j2
+++ b/ftc/jinja2/index.html.j2
@@ -127,7 +127,9 @@
{% block bodyscripts %}
diff --git a/ftc/models/organisation.py b/ftc/models/organisation.py
index 4c624e02..1b65b3db 100644
--- a/ftc/models/organisation.py
+++ b/ftc/models/organisation.py
@@ -282,7 +282,7 @@ def all_names(self):
@classmethod
def get_fields_as_properties(cls):
- internal_fields = ["scrape", "spider", "id"]
+ internal_fields = ["scrape", "spider", "id", "priority"]
return [
{"id": f.name, "name": f.verbose_name}
for f in cls._meta.get_fields()
diff --git a/ftc/tests/__init__.py b/ftc/tests/__init__.py
index 63a61873..0a4b72fa 100644
--- a/ftc/tests/__init__.py
+++ b/ftc/tests/__init__.py
@@ -4,7 +4,14 @@
import django.test
from django.utils import timezone
-from ftc.models import Organisation, OrganisationType, Scrape, Source
+from ftc.models import (
+ Organisation,
+ OrganisationClassification,
+ OrganisationType,
+ Scrape,
+ Source,
+ Vocabulary,
+)
class TestCase(django.test.TestCase):
@@ -20,7 +27,8 @@ def setUp(self):
ot2 = OrganisationType.objects.create(
title="Registered Charity (England and Wales)"
)
- s = Source.objects.create(
+ OrganisationType.objects.create(title="Local Authority")
+ self.source = Source.objects.create(
id="ts",
data={
"title": "Test source",
@@ -29,7 +37,7 @@ def setUp(self):
},
},
)
- scrape = Scrape.objects.create(
+ self.scrape = Scrape.objects.create(
status=Scrape.ScrapeStatus.SUCCESS,
spider="test",
errors=0,
@@ -38,7 +46,7 @@ def setUp(self):
start_time=timezone.now() - datetime.timedelta(minutes=10),
finish_time=timezone.now() - datetime.timedelta(minutes=5),
)
- Organisation.objects.create(
+ organisation = Organisation.objects.create(
org_id="GB-CHC-1234",
orgIDs=["GB-CHC-1234"],
linked_orgs=["GB-CHC-1234"],
@@ -46,8 +54,8 @@ def setUp(self):
name="Test organisation",
active=True,
organisationTypePrimary=ot,
- source=s,
- scrape=scrape,
+ source=self.source,
+ scrape=self.scrape,
organisationType=[ot.slug, ot2.slug],
)
Organisation.objects.create(
@@ -58,8 +66,8 @@ def setUp(self):
name="Test organisation 2",
active=True,
organisationTypePrimary=ot,
- source=s,
- scrape=scrape,
+ source=self.source,
+ scrape=self.scrape,
organisationType=[ot.slug, ot2.slug],
)
Organisation.objects.create(
@@ -70,7 +78,40 @@ def setUp(self):
name="Test organisation 3",
active=True,
organisationTypePrimary=ot,
- source=s,
- scrape=scrape,
+ source=self.source,
+ scrape=self.scrape,
organisationType=[ot.slug, ot2.slug],
)
+
+ # vocabulary
+ test_vocab = Vocabulary.objects.create(
+ slug="test-vocab",
+ title="Test Vocabulary",
+ description="Test vocabulary description",
+ single=False,
+ )
+ vocab_entry_a = test_vocab.entries.create(code="A", title="A")
+ vocab_entry_b = test_vocab.entries.create(code="B", title="B")
+ vocab_entry_c = test_vocab.entries.create(code="C", title="C")
+
+ OrganisationClassification.objects.create(
+ org_id=organisation.org_id,
+ vocabulary=vocab_entry_a,
+ spider="test",
+ source=self.source,
+ scrape=self.scrape,
+ )
+ OrganisationClassification.objects.create(
+ org_id=organisation.org_id,
+ vocabulary=vocab_entry_b,
+ spider="test",
+ source=self.source,
+ scrape=self.scrape,
+ )
+ OrganisationClassification.objects.create(
+ org_id=organisation.org_id,
+ vocabulary=vocab_entry_c,
+ spider="test",
+ source=self.source,
+ scrape=self.scrape,
+ )
diff --git a/ftc/tests/data/random_organisation_response.json b/ftc/tests/data/random_organisation_response.json
new file mode 100644
index 00000000..3a4b73d8
--- /dev/null
+++ b/ftc/tests/data/random_organisation_response.json
@@ -0,0 +1,59 @@
+{
+ "took": 241,
+ "timed_out": false,
+ "_shards": {
+ "total": 1,
+ "successful": 1,
+ "skipped": 0,
+ "failed": 0
+ },
+ "hits": {
+ "total": {
+ "value": 10000,
+ "relation": "gte"
+ },
+ "max_score": 2.169083,
+ "hits": [
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1234",
+ "_score": 2.169083,
+ "_source": {
+ "org_id": "GB-CHC-1234",
+ "complete_names": [
+ "Test organisation",
+ "Test"
+ ],
+ "orgIDs": [
+ "GB-CHC-1234"
+ ],
+ "ids": [
+ "1234"
+ ],
+ "sortname": "test organisation",
+ "alternateName": [],
+ "domain": [],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales",
+ "charitable-incorporated-organisation",
+ "incorporated-charity",
+ "charitable-incorporated-organisation-foundation",
+ "registered-company"
+ ],
+ "organisationTypePrimary": "registered-charity",
+ "source": [
+ "ts"
+ ],
+ "locations": [],
+ "search_scale": 9.705165419719007,
+ "name": "Test organisation",
+ "postalCode": "SW1A 1AA",
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "active": true
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/ftc/tests/test_company_api.py b/ftc/tests/test_company_api.py
new file mode 100644
index 00000000..2379c199
--- /dev/null
+++ b/ftc/tests/test_company_api.py
@@ -0,0 +1,29 @@
+import logging
+
+from charity_django.companies.models import Company
+
+from ftc.tests import TestCase
+
+logger = logging.getLogger(__name__)
+
+
+class TestCompanyAPI(TestCase):
+ def setUp(self):
+ super().setUp()
+ Company.objects.create(
+ CompanyNumber="12345678",
+ CompanyName="Test organisation",
+ )
+
+ # GET request to /api/v1/companies/__id__ should return an organisation
+ def test_get_companies(self):
+ response = self.client.get("/api/v1/companies/12345678")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"]["CompanyName"], "Test organisation")
+
+ def test_get_company_missing(self):
+ response = self.client.get("/api/v1/companies/BLAHBLAH")
+ self.assertEqual(response.status_code, 404)
+ data = response.json()
+ self.assertEqual(data["error"], "No Company matches the given query.")
diff --git a/ftc/tests/test_organisation_api.py b/ftc/tests/test_organisation_api.py
new file mode 100644
index 00000000..2b3e0a06
--- /dev/null
+++ b/ftc/tests/test_organisation_api.py
@@ -0,0 +1,90 @@
+import json
+import logging
+import os
+
+from ftc.tests import TestCase
+
+logger = logging.getLogger(__name__)
+
+with open(
+ os.path.join(os.path.dirname(__file__), "data", "random_organisation_response.json")
+) as f:
+ RANDOM_ORG_RESPONSE = json.load(f)
+
+
+class TestOrganisationAPI(TestCase):
+ # GET request to /api/v1/organisation/__id__ should return an organisation
+ def test_get_organisation(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-1234")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ data["result"]["organisationTypePrimary"]["title"], "Registered Charity"
+ )
+ self.assertEqual(data["result"]["description"], "Test description")
+
+ def test_get_organisationmissing(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-BLAHBLAH")
+ self.assertEqual(response.status_code, 404)
+ data = response.json()
+ self.assertEqual(data["error"], "No Organisation found.")
+
+ def test_get_random_organisation(self):
+ self.mock_es.return_value.search.return_value = RANDOM_ORG_RESPONSE
+
+ response = self.client.get("/api/v1/organisations/_random")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ data["result"]["organisationTypePrimary"]["title"], "Registered Charity"
+ )
+ self.assertEqual(data["result"]["description"], "Test description")
+
+ def test_get_canonical_organisation(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-6/canonical")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ data["result"]["organisationTypePrimary"]["title"], "Registered Charity"
+ )
+ self.assertEqual(data["result"]["id"], "GB-CHC-5")
+
+ def test_get_canonical_organisation_same(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-5/canonical")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ data["result"]["organisationTypePrimary"]["title"], "Registered Charity"
+ )
+ self.assertEqual(data["result"]["id"], "GB-CHC-5")
+
+ def test_get_canonical_organisation_single(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-1234/canonical")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ data["result"]["organisationTypePrimary"]["title"], "Registered Charity"
+ )
+ self.assertEqual(data["result"]["id"], "GB-CHC-1234")
+
+ def test_get_linked_organisations(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-5/linked")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["count"], 2)
+ self.assertEqual(data["count"], len(data["result"]))
+ self.assertEqual(data["result"][0]["name"], "Test organisation 2")
+
+ def test_get_organisation_source(self):
+ response = self.client.get("/api/v1/organisations/GB-CHC-1234/source")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"]["publisher"], "Source publisher")
+
+ def test_filter_organisations(self):
+ response = self.client.get("/api/v1/organisations?active=true")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(len(data["result"]), 3)
+ ids = sorted([x["id"] for x in data["result"]])
+ self.assertEqual(ids, ["GB-CHC-1234", "GB-CHC-5", "GB-CHC-6"])
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..275d40f4
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = findthatcharity.settings
\ No newline at end of file
diff --git a/readme.md b/readme.md
index c5d13585..5d8ffb06 100644
--- a/readme.md
+++ b/readme.md
@@ -1,5 +1,4 @@
-Find that charity
-================
+# Find that charity
Elasticsearch-powered search engine for looking for charities and other non-profit organisations. Allows for:
@@ -12,8 +11,7 @@ Elasticsearch-powered search engine for looking for charities and other non-prof
charity number.
- HTML pages for searching for a charity
-Installation
-------------
+## Installation
1. [Clone repository](https://github.com/kanedata/find-that-charity)
2. Create virtual environment (`python -m venv env`)
@@ -31,8 +29,7 @@ Installation
14. Import data on other non-profit organisations (`python ./manage.py import_all`)
15. Add organisations to elasticsearch index (`python ./manage.py es_index`) - (Don't use the default `search_index` command as this won't setup aliases correctly)
-Dokku Installation
-------------------
+## Dokku Installation
### 1. Set up dokku server
@@ -102,8 +99,7 @@ dokku run ftc python ./manage.py import_all
dokku run ftc python ./manage.py es_index
```
-Server
-------
+## Server
The server uses [django](https://www.djangoproject.com/). Run it with the
following command:
@@ -117,8 +113,7 @@ The server offers the following API endpoints:
- `/charity/12345`: Look up information about a particular charity
-Todo
-----
+## Todo
Priorities:
@@ -133,10 +128,9 @@ Future development:
- upload a CSV file and reconcile each row with a charity
- allow updating a charity with additional possible names
-Testing
--------
+## Testing
```sh
-coverage run manage.py test && coverage html
+coverage run pytest && coverage html
python -m http.server -d htmlcov --bind 127.0.0.1 8001
```
diff --git a/reconcile/api/__init__.py b/reconcile/api/__init__.py
index bd9c1e7e..f35d5e24 100644
--- a/reconcile/api/__init__.py
+++ b/reconcile/api/__init__.py
@@ -1,3 +1,13 @@
-from .reconcile import api
+from ninja import Router
+
+from .reconcile_all import api as all_api
+from .reconcile_company import api as company_api
+from .reconcile_orgtype import api as orgtype_api
+
+api = Router(tags=["Reconciliation"])
+api.add_router("", all_api)
+api.add_router("/company", company_api)
+api.add_router("", orgtype_api)
+
__all__ = ["api"]
diff --git a/reconcile/api/base.py b/reconcile/api/base.py
new file mode 100644
index 00000000..2fe76c6d
--- /dev/null
+++ b/reconcile/api/base.py
@@ -0,0 +1,246 @@
+import urllib.parse
+from typing import Dict, List, Literal, Optional, Union
+
+from django.http import Http404
+from django.shortcuts import reverse
+
+from findthatcharity.jinja2 import get_orgtypes
+from ftc.documents import OrganisationGroup
+from ftc.models import Organisation, OrganisationType, Vocabulary
+from ftc.views import get_org_by_id
+from reconcile.query import do_extend_query, do_reconcile_query
+from reconcile.utils import convert_value
+
+from .schema import (
+ DataExtensionQuery,
+ ReconciliationCandidate,
+ ReconciliationQueryBatch,
+)
+
+
+class Reconcile:
+ name = "Find that Charity Reconciliation API"
+ view_url = "orgid_html"
+ view_url_args = {"org_id": "{{id}}"}
+ suggest = True
+ extend = True
+ preview = True
+
+ def reconcile_query(self, *args, **kwargs):
+ return do_reconcile_query(*args, **kwargs)
+
+ def _get_orgtypes_from_str(
+ self, orgtype: Optional[Union[List[str], str]] = None
+ ) -> List[OrganisationType]:
+ if orgtype == "all":
+ return []
+ if isinstance(orgtype, str):
+ return [OrganisationType.objects.get(slug=o) for o in orgtype.split("+")]
+ if isinstance(orgtype, list):
+ orgtypes = []
+ for o in orgtype:
+ if o == "all":
+ continue
+ if isinstance(o, str):
+ orgtypes.append(OrganisationType.objects.get(slug=o))
+ elif isinstance(o, OrganisationType):
+ orgtypes.append(o)
+ return orgtypes
+ return []
+
+ def get_service_spec(
+ self,
+ request,
+ orgtypes: Optional[Union[List[str], Literal["all"]]] = None,
+ defaultTypes: Optional[List[dict]] = None,
+ ):
+ if orgtypes:
+ orgtypes = self._get_orgtypes_from_str(orgtypes)
+ if not defaultTypes:
+ if not orgtypes or orgtypes == "all":
+ defaultTypes = [{"id": "/Organization", "name": "Organisation"}]
+ elif isinstance(orgtypes, list):
+ defaultTypes = [{"id": o.slug, "name": o.title} for o in orgtypes]
+
+ request_path = request.build_absolute_uri(request.path).rstrip("/")
+
+ spec = {
+ "versions": ["0.2"],
+ "name": self.name,
+ "identifierSpace": "http://org-id.guide",
+ "schemaSpace": "https://schema.org",
+ "view": {
+ "url": urllib.parse.unquote(
+ request.build_absolute_uri(
+ reverse(self.view_url, kwargs=self.view_url_args)
+ )
+ )
+ },
+ "defaultTypes": defaultTypes,
+ }
+ if self.preview:
+ spec["preview"] = {
+ "url": request_path + "/preview?id={{id}}",
+ "width": 430,
+ "height": 300,
+ }
+ if self.extend:
+ spec["extend"] = {
+ "propose_properties": {
+ "service_url": request_path,
+ "service_path": "/extend/propose",
+ },
+ "property_settings": [],
+ }
+ if self.suggest:
+ spec["suggest"] = {
+ "entity": {
+ "service_url": request_path,
+ "service_path": "/suggest/entity",
+ },
+ }
+ return spec
+
+ def reconcile(
+ self,
+ request,
+ body: ReconciliationQueryBatch,
+ orgtypes: List[OrganisationType] | List[str] | Literal["all"] = None,
+ ) -> Dict[str, List[ReconciliationCandidate]]:
+ orgtypes = self._get_orgtypes_from_str(orgtypes)
+ results = {}
+ for key, query in body.queries.items():
+ results[key] = self.reconcile_query(
+ query.query,
+ type=query.type,
+ limit=query.limit,
+ properties=query.properties,
+ type_strict=query.type_strict,
+ result_key="result",
+ orgtypes=orgtypes,
+ )
+ return results
+
+ def preview_view(
+ self,
+ request,
+ id: str,
+ orgtypes: Optional[Union[List[str], Literal["all"]]] = None,
+ ):
+ return get_org_by_id(request, id, preview=True)
+
+ def suggest_entity(
+ self,
+ request,
+ prefix: str,
+ cursor: int = 0,
+ orgtypes: Optional[Union[List[str], Literal["all"]]] = None,
+ ):
+ orgtypes = self._get_orgtypes_from_str(orgtypes)
+ SUGGEST_NAME = "name_complete"
+
+ # cursor = request.GET.get("cursor")
+ if not prefix:
+ raise Http404("Prefix must be supplied")
+ q = OrganisationGroup.search()
+
+ orgtype = []
+ if isinstance(orgtypes, list):
+ for o in orgtypes:
+ if o == "all":
+ continue
+ orgtype.append(o.slug)
+
+ completion = {"field": "complete_names", "fuzzy": {"fuzziness": 1}}
+ if orgtype:
+ completion["contexts"] = dict(organisationType=orgtype)
+ else:
+ all_orgtypes = get_orgtypes()
+ completion["contexts"] = dict(
+ organisationType=[o for o in all_orgtypes.keys()]
+ )
+
+ q = q.suggest(SUGGEST_NAME, prefix, completion=completion).source(
+ ["org_id", "name", "organisationType"]
+ )
+ result = q.execute()
+
+ return {
+ "result": [
+ {
+ "id": r["_source"]["org_id"],
+ "name": r["_source"]["name"],
+ "notable": list(r["_source"]["organisationType"]),
+ }
+ for r in result.suggest[SUGGEST_NAME][0]["options"]
+ ]
+ }
+
+ def propose_properties(self, request, type_, limit=500):
+ if type_ != "Organization":
+ raise Http404("type must be Organization")
+
+ organisation_properties = Organisation.get_fields_as_properties()
+
+ vocabulary_properties = [
+ {"id": "vocab-" + v.slug, "name": v.title, "group": "Vocabulary"}
+ for v in Vocabulary.objects.all()
+ if v.entries.count() > 0
+ ]
+
+ ccew_properties = [
+ {
+ "id": "ccew-parta-total_gross_expenditure",
+ "name": "Total Expenditure",
+ "group": "Charity",
+ },
+ {
+ "id": "ccew-partb-count_employees",
+ "name": "Number of staff",
+ "group": "Charity",
+ },
+ {
+ "id": "ccew-partb-expenditure_charitable_expenditure",
+ "name": "Charitable expenditure",
+ "group": "Charity",
+ },
+ {
+ "id": "ccew-partb-expenditure_grants_institution",
+ "name": "Grantmaking expenditure",
+ "group": "Charity",
+ },
+ {
+ "id": "ccew-gd-charitable_objects",
+ "name": "Objects",
+ "group": "Charity",
+ },
+ {
+ "id": "ccew-aoo-geographic_area_description",
+ "name": "Area of Operation",
+ "group": "Charity",
+ },
+ ]
+
+ return {
+ "limit": limit,
+ "type": type_,
+ "properties": organisation_properties
+ + vocabulary_properties
+ + ccew_properties,
+ }
+
+ def data_extension(self, request, body: DataExtensionQuery) -> Dict:
+ result = do_extend_query(
+ ids=body.ids,
+ properties=[p.__dict__ for p in body.properties],
+ )
+ rows = {}
+ for row_id, row in result["rows"].items():
+ rows[row_id] = {}
+ for k, v in row.items():
+ rows[row_id][k] = convert_value(v)
+
+ return {
+ "meta": result["meta"],
+ "rows": rows,
+ }
diff --git a/reconcile/api/reconcile.py b/reconcile/api/reconcile.py
deleted file mode 100644
index 5acbbadd..00000000
--- a/reconcile/api/reconcile.py
+++ /dev/null
@@ -1,407 +0,0 @@
-import json
-import urllib.parse
-from typing import Dict, List, Literal, Optional, Union
-
-from django.http import Http404, HttpResponse
-from django.shortcuts import reverse
-from ninja import Form, Router
-
-from findthatcharity.jinja2 import get_orgtypes
-from ftc.documents import OrganisationGroup
-from ftc.models import Organisation, OrganisationType, Vocabulary
-from ftc.views import get_org_by_id
-from reconcile.companies import COMPANY_RECON_TYPE
-from reconcile.companies import do_reconcile_query as do_companies_reconcile_query
-from reconcile.query import do_extend_query, do_reconcile_query
-
-from .schema import (
- DataExtensionPropertyProposalResponse,
- DataExtensionQuery,
- DataExtensionQueryResponse,
- ReconciliationCandidate,
- ReconciliationQuery,
- ReconciliationQueryBatch,
- ReconciliationQueryBatchForm,
- ReconciliationResult,
- ReconciliationResultBatch,
- ServiceSpec,
- SuggestResponse,
-)
-
-
-def get_orgtypes_from_str(
- orgtype: Optional[Union[List[str], str]] = None
-) -> List[OrganisationType]:
- if orgtype == "all":
- return []
- if isinstance(orgtype, str):
- return [OrganisationType.objects.get(slug=o) for o in orgtype.split("+")]
- if isinstance(orgtype, list):
- return [OrganisationType.objects.get(slug=o) for o in orgtype]
- return []
-
-
-api = Router(tags=["Reconciliation (against all organisations)"])
-
-
-def _get_service_spec(
- request, orgtypes: Optional[Union[List[str], Literal["all"]]] = None
-):
- if not orgtypes or orgtypes == "all":
- defaultTypes = [{"id": "/Organization", "name": "Organisation"}]
- elif isinstance(orgtypes, list):
- defaultTypes = [{"id": o.slug, "name": o.title} for o in orgtypes]
-
- return {
- "versions": ["0.2"],
- "name": "Find that Charity Reconciliation API",
- "identifierSpace": "http://org-id.guide",
- "schemaSpace": "https://schema.org",
- "view": {
- "url": urllib.parse.unquote(
- request.build_absolute_uri(
- reverse("orgid_html", kwargs={"org_id": "{{id}}"})
- )
- )
- },
- "preview": {
- "width": 430,
- "height": 300,
- },
- "defaultTypes": defaultTypes,
- "extend": {
- "propose_properties": True,
- "property_settings": [],
- },
- "suggest": {
- "entity": True,
- },
- }
-
-
-def _reconcile(
- request, body: ReconciliationQueryBatch, orgtypes: List[OrganisationType]
-) -> Dict[str, List[ReconciliationCandidate]]:
- results = {}
- for key, query in body.queries.items():
- results.update(
- do_reconcile_query(
- query.query,
- type=query.type,
- limit=query.limit,
- properties=query.properties,
- type_strict=query.type_strict,
- result_key=key,
- orgtypes=orgtypes,
- )
- )
- return results
-
-
-def _preview(
- request,
- id: str,
- orgtypes: Optional[Union[List[str], Literal["all"]]] = None,
-):
- return get_org_by_id(request, id, preview=True)
-
-
-def _suggest_entity(
- request,
- prefix: str,
- cursor: int = 0,
- orgtypes: Optional[Union[List[str], Literal["all"]]] = None,
-):
- SUGGEST_NAME = "name_complete"
-
- # cursor = request.GET.get("cursor")
- if not prefix:
- raise Http404("Prefix must be supplied")
- q = OrganisationGroup.search()
-
- orgtype = []
- if isinstance(orgtypes, list):
- for o in orgtypes:
- if o == "all":
- continue
- orgtype.append(o.slug)
-
- completion = {"field": "complete_names", "fuzzy": {"fuzziness": 1}}
- if orgtype:
- completion["contexts"] = dict(organisationType=orgtype)
- else:
- all_orgtypes = get_orgtypes()
- completion["contexts"] = dict(organisationType=[o for o in all_orgtypes.keys()])
-
- q = q.suggest(SUGGEST_NAME, prefix, completion=completion).source(
- ["org_id", "name", "organisationType"]
- )
- result = q.execute()
-
- return {
- "result": [
- {
- "id": r["_source"]["org_id"],
- "name": r["_source"]["name"],
- "notable": list(r["_source"]["organisationType"]),
- }
- for r in result.suggest[SUGGEST_NAME][0]["options"]
- ]
- }
-
-
-def _propose_properties(request, type_, limit=500):
- if type_ != "Organization":
- raise Http404("type must be Organization")
-
- organisation_properties = Organisation.get_fields_as_properties()
-
- vocabulary_properties = [
- {"id": "vocab-" + v.slug, "name": v.title, "group": "Vocabulary"}
- for v in Vocabulary.objects.all()
- if v.entries.count() > 0
- ]
-
- ccew_properties = [
- {
- "id": "ccew-parta-total_gross_expenditure",
- "name": "Total Expenditure",
- "group": "Charity",
- },
- {
- "id": "ccew-partb-count_employees",
- "name": "Number of staff",
- "group": "Charity",
- },
- {
- "id": "ccew-partb-expenditure_charitable_expenditure",
- "name": "Charitable expenditure",
- "group": "Charity",
- },
- {
- "id": "ccew-partb-expenditure_grants_institution",
- "name": "Grantmaking expenditure",
- "group": "Charity",
- },
- {
- "id": "ccew-gd-charitable_objects",
- "name": "Objects",
- "group": "Charity",
- },
- {
- "id": "ccew-aoo-geographic_area_description",
- "name": "Area of Operation",
- "group": "Charity",
- },
- ]
-
- return {
- "limit": limit,
- "type": type_,
- "properties": organisation_properties + vocabulary_properties + ccew_properties,
- }
-
-
-def _data_extension(request, body: DataExtensionQuery):
- def convert_value(v):
- if isinstance(v, dict):
- return v
- elif isinstance(v, list):
- return [convert_value(vv)[0] for vv in v]
- elif isinstance(v, str):
- return [{"str": v}]
- elif isinstance(v, int):
- return [{"int": v}]
- elif isinstance(v, float):
- return [{"float": v}]
- elif isinstance(v, bool):
- return [{"bool": v}]
- else:
- return [{"str": str(v)}]
-
- result = do_extend_query(
- ids=body.ids,
- properties=[p.__dict__ for p in body.properties],
- )
- return {
- "meta": result["meta"],
- "rows": [
- {
- "id": row_id,
- "properties": [
- {"id": k, "values": convert_value(v)} for k, v in row.items()
- ],
- }
- for row_id, row in result["rows"].items()
- ],
- }
-
-
-@api.get(
- "",
- response={200: ServiceSpec},
- exclude_none=True,
- summary="Service specification for reconciling against any nonprofit organisation",
- description="Get the service specification for reconciling against any nonprofit organisation",
-)
-def get_service_spec(request):
- return _get_service_spec(request, orgtypes="all")
-
-
-@api.post(
- "",
- response={200: Dict[str, List[ReconciliationCandidate]]},
- exclude_none=True,
- summary="Reconciliation endpoint for reconciling against any nonprofit organisation",
- description="Reconciling queries against any nonprofit organisation.",
-)
-def reconcile(
- request,
- queries: Form[ReconciliationQueryBatchForm],
-):
- queries_parsed = ReconciliationQueryBatch(queries=json.loads(queries.queries))
- return _reconcile(request, queries_parsed, orgtypes="all")
-
-
-@api.get("/preview")
-def preview(request, id: str, response: HttpResponse):
- return _preview(request, id, response)
-
-
-@api.get("/suggest/entity", response={200: SuggestResponse}, exclude_none=True)
-def suggest_entity(request, prefix: str, cursor: int = 0):
- return _suggest_entity(request, prefix, cursor)
-
-
-@api.get(
- "/extend/propose",
- response={200: DataExtensionPropertyProposalResponse},
- exclude_none=True,
-)
-def propose_properties(request, type: str, limit: int = 500):
- return _propose_properties(request, type_=type, limit=limit)
-
-
-@api.post(
- "/extend",
- response={200: DataExtensionQueryResponse},
- exclude_none=True,
-)
-def data_extension(request, body: DataExtensionQuery):
- return _data_extension(request, body)
-
-
-@api.get(
- "/company",
- response={200: ServiceSpec},
- exclude_none=True,
- tags=["Reconciliation (against registered companies)"],
-)
-def get_company_service_spec(request):
- return {
- "versions": ["0.2"],
- "name": "Find that Charity Company Reconciliation API",
- "identifierSpace": "http://org-id.guide",
- "schemaSpace": "https://schema.org",
- "view": {
- "url": urllib.parse.unquote(
- request.build_absolute_uri(
- reverse("company_detail", kwargs={"company_number": "{{id}}"})
- )
- )
- },
- # "preview": {
- # "width": 430,
- # "height": 300,
- # },
- "defaultTypes": [COMPANY_RECON_TYPE],
- }
-
-
-@api.post(
- "/company",
- response={200: Dict[str, List[ReconciliationCandidate]]},
- exclude_none=True,
- tags=["Reconciliation (against registered companies)"],
-)
-def company_reconcile_query(
- request,
- queries: Form[ReconciliationQueryBatchForm],
-):
- queries_parsed = ReconciliationQueryBatch(queries=json.loads(queries.queries))
- return {
- key: do_companies_reconcile_query(
- query.query,
- type=query.type,
- limit=query.limit,
- properties=query.properties,
- type_strict=query.type_strict,
- result_key="candidates",
- )["candidates"]
- for key, query in queries_parsed.queries.items()
- }
-
-
-@api.get(
- "/{orgtype}",
- response={200: ServiceSpec},
- exclude_none=True,
- tags=["Reconciliation (against specific type of organisation)"],
- summary="Service specification for reconciling against a specific type of organisation",
- description="Get the service specification for reconciling against a specific type of organisation",
-)
-def orgtype_get_service_spec(request, orgtype: str):
- return _get_service_spec(request, orgtypes=get_orgtypes_from_str(orgtype))
-
-
-@api.post(
- "/{orgtype}",
- response={200: ReconciliationResultBatch},
- exclude_none=True,
- tags=["Reconciliation (against specific type of organisation)"],
- summary="Reconciliation endpoint for reconciling against a specific type of organisation",
- description="Reconciling queries against a specific type of organisation. You can specify multiple types of organisation by separating them with a '+'.",
-)
-def orgtype_reconcile(request, orgtype: str, body: ReconciliationQueryBatch):
- return _reconcile(request, body, orgtypes=get_orgtypes_from_str(orgtype))
-
-
-@api.get(
- "/{orgtype}/preview",
- tags=["Reconciliation (against specific type of organisation)"],
- summary="HTML preview of an organisation",
-)
-def orgtype_preview(request, orgtype: str, id: str, response: HttpResponse):
- return _preview(request, id, response)
-
-
-@api.get(
- "/{orgtype}/suggest/entity",
- response={200: SuggestResponse},
- exclude_none=True,
- tags=["Reconciliation (against specific type of organisation)"],
-)
-def orgtype_suggest_entity(request, orgtype: str, prefix: str, cursor: int = 0):
- return _suggest_entity(
- request, prefix, cursor, orgtypes=get_orgtypes_from_str(orgtype)
- )
-
-
-@api.get(
- "/{orgtype}/extend/propose",
- response={200: DataExtensionPropertyProposalResponse},
- exclude_none=True,
- tags=["Reconciliation (against specific type of organisation)"],
-)
-def orgtype_propose_properties(request, orgtype: str, type: str, limit: int = 500):
- return _propose_properties(request, type_=type, limit=limit)
-
-
-@api.post(
- "/{orgtype}/extend",
- response={200: DataExtensionQueryResponse},
- exclude_none=True,
- tags=["Reconciliation (against specific type of organisation)"],
-)
-def orgtype_data_extension(request, orgtype: str, body: DataExtensionQuery):
- return _data_extension(request, body)
diff --git a/reconcile/api/reconcile_all.py b/reconcile/api/reconcile_all.py
new file mode 100644
index 00000000..4152b6cb
--- /dev/null
+++ b/reconcile/api/reconcile_all.py
@@ -0,0 +1,79 @@
+import json
+from typing import Dict
+
+from django.http import HttpResponse
+from ninja import Form, Router
+
+from reconcile.api.base import Reconcile
+
+from .schema import (
+ DataExtensionPropertyProposalResponse,
+ DataExtensionQuery,
+ DataExtensionQueryResponse,
+ ReconciliationQueryBatch,
+ ReconciliationQueryBatchForm,
+ ReconciliationResult,
+ ServiceSpec,
+ SuggestResponse,
+)
+
+api = Router(tags=["Reconciliation (against all organisations)"])
+reconcile = Reconcile()
+
+
+@api.get(
+ path="",
+ response={200: ServiceSpec},
+ exclude_none=True,
+ summary="Service specification for reconciling against any nonprofit organisation",
+ description="Get the service specification for reconciling against any nonprofit organisation",
+)
+def get_service_spec(request):
+ return reconcile.get_service_spec(request, orgtypes="all")
+
+
+@api.post(
+ "",
+ response={200: Dict[str, ReconciliationResult] | DataExtensionQueryResponse},
+ exclude_none=True,
+ summary="Reconciliation endpoint for reconciling against any nonprofit organisation",
+ description="Reconciling queries against any nonprofit organisation.",
+)
+def reconcile_entities(
+ request,
+ queries: Form[ReconciliationQueryBatchForm],
+):
+ if queries.queries:
+ queries_parsed = ReconciliationQueryBatch(queries=json.loads(queries.queries))
+ return reconcile.reconcile(request, queries_parsed, orgtypes="all")
+ elif queries.extend:
+ queries_parsed = DataExtensionQuery(**json.loads(queries.extend))
+ return reconcile.data_extension(request, queries_parsed)
+
+
+@api.get("/preview")
+def preview(request, id: str, response: HttpResponse):
+ return reconcile.preview_view(request, id, response)
+
+
+@api.get("/suggest/entity", response={200: SuggestResponse}, exclude_none=True)
+def suggest_entity(request, prefix: str, cursor: int = 0):
+ return reconcile.suggest_entity(request, prefix, cursor)
+
+
+@api.get(
+ "/extend/propose",
+ response={200: DataExtensionPropertyProposalResponse},
+ exclude_none=True,
+)
+def propose_properties(request, type: str, limit: int = 500):
+ return reconcile.propose_properties(request, type_=type, limit=limit)
+
+
+@api.post(
+ "/extend",
+ response={200: DataExtensionQueryResponse},
+ exclude_none=True,
+)
+def data_extension(request, body: DataExtensionQuery):
+ return reconcile.data_extension(request, body)
diff --git a/reconcile/api/reconcile_company.py b/reconcile/api/reconcile_company.py
new file mode 100644
index 00000000..45d8ab68
--- /dev/null
+++ b/reconcile/api/reconcile_company.py
@@ -0,0 +1,55 @@
+import json
+from typing import Dict
+
+from ninja import Form, Router
+
+from reconcile.companies import COMPANY_RECON_TYPE, do_reconcile_query
+
+from .base import Reconcile
+from .schema import (
+ ReconciliationQueryBatch,
+ ReconciliationQueryBatchForm,
+ ReconciliationResult,
+ ServiceSpec,
+)
+
+api = Router(tags=["Reconciliation (against registered companies)"])
+
+
+class CompanyReconcile(Reconcile):
+ name = "Find that Charity Company Reconciliation API"
+ view_url = "company_detail"
+ view_url_args = {"company_number": "{{id}}"}
+ suggest = False
+ extend = False
+ preview = False
+
+ def reconcile_query(self, *args, **kwargs):
+ return do_reconcile_query(*args, **kwargs)
+
+
+reconcile = CompanyReconcile()
+
+
+@api.get(
+ "",
+ response={200: ServiceSpec},
+ exclude_none=True,
+)
+def get_company_service_spec(request):
+ return reconcile.get_service_spec(request, defaultTypes=[COMPANY_RECON_TYPE])
+
+
+@api.post(
+ "",
+ response={200: Dict[str, ReconciliationResult]},
+ exclude_none=True,
+ summary="Reconciliation endpoint for reconciling against registered companies",
+ description="Reconciling queries against registered companies.",
+)
+def company_reconcile_entities(
+ request,
+ queries: Form[ReconciliationQueryBatchForm],
+):
+ queries_parsed = ReconciliationQueryBatch(queries=json.loads(queries.queries))
+ return reconcile.reconcile(request, queries_parsed)
diff --git a/reconcile/api/reconcile_orgtype.py b/reconcile/api/reconcile_orgtype.py
new file mode 100644
index 00000000..5b4bd6f0
--- /dev/null
+++ b/reconcile/api/reconcile_orgtype.py
@@ -0,0 +1,93 @@
+import json
+from typing import Dict
+
+from django.http import HttpResponse
+from ninja import Form, Path, Router
+
+from reconcile.api.base import Reconcile
+
+from .schema import (
+ DataExtensionPropertyProposalResponse,
+ DataExtensionQuery,
+ DataExtensionQueryResponse,
+ ReconciliationQueryBatch,
+ ReconciliationQueryBatchForm,
+ ReconciliationResult,
+ ServiceSpec,
+ SuggestResponse,
+)
+
+api = Router(tags=["Reconciliation (against specific type of organisation)"])
+reconcile = Reconcile()
+
+
+@api.get(
+ "/{orgtype}",
+ response={200: ServiceSpec},
+ exclude_none=True,
+ tags=["Reconciliation (against specific type of organisation)"],
+ summary="Service specification for reconciling against a specific type of organisation",
+ description="Get the service specification for reconciling against a specific type of organisation",
+)
+def orgtype_get_service_spec(request, orgtype: str):
+ return reconcile.get_service_spec(request, orgtypes=orgtype)
+
+
+@api.post(
+ "/{orgtype}",
+ response={200: Dict[str, ReconciliationResult] | DataExtensionQueryResponse},
+ exclude_none=True,
+ tags=["Reconciliation (against specific type of organisation)"],
+ summary="Reconciliation endpoint for reconciling against a specific type of organisation",
+ description="Reconciling queries against a specific type of organisation. You can specify multiple types of organisation by separating them with a '+'.",
+)
+def orgtype_reconcile_entities(
+ request,
+ orgtype: Path[str],
+ queries: Form[ReconciliationQueryBatchForm],
+):
+ if queries.queries:
+ queries_parsed = ReconciliationQueryBatch(queries=json.loads(queries.queries))
+ return reconcile.reconcile(request, queries_parsed, orgtypes=orgtype)
+ elif queries.extend:
+ queries_parsed = DataExtensionQuery(**json.loads(queries.extend))
+ return reconcile.data_extension(request, queries_parsed)
+
+
+@api.get(
+ "/{orgtype}/preview",
+ tags=["Reconciliation (against specific type of organisation)"],
+ summary="HTML preview of an organisation",
+)
+def orgtype_preview(request, orgtype: str, id: str, response: HttpResponse):
+ return reconcile.preview_view(request, id, response)
+
+
+@api.get(
+ "/{orgtype}/suggest/entity",
+ response={200: SuggestResponse},
+ exclude_none=True,
+ tags=["Reconciliation (against specific type of organisation)"],
+)
+def orgtype_suggest_entity(request, orgtype: str, prefix: str, cursor: int = 0):
+ return reconcile.suggest_entity(request, prefix, cursor, orgtypes=orgtype)
+
+
+@api.get(
+ "/{orgtype}/extend/propose",
+ response={200: DataExtensionPropertyProposalResponse},
+ exclude_none=True,
+ tags=["Reconciliation (against specific type of organisation)"],
+)
+def orgtype_propose_properties(request, orgtype: str, type: str, limit: int = 500):
+ return reconcile.propose_properties(request, type_=type, limit=limit)
+
+
+@api.post(
+ "/{orgtype}/extend",
+ response={200: DataExtensionQueryResponse},
+ exclude_none=True,
+ tags=["Reconciliation (against specific type of organisation)"],
+)
+def orgtype_data_extension(request, orgtype: str, body: DataExtensionQuery):
+ return reconcile.data_extension(request, body)
diff --git a/reconcile/api/schema.py b/reconcile/api/schema.py
index 15f5e014..676c9bdb 100644
--- a/reconcile/api/schema.py
+++ b/reconcile/api/schema.py
@@ -1,6 +1,14 @@
+from enum import Enum
from typing import Dict, List, Literal, Optional, TypeVar, Union
from ninja import Schema
+from pydantic import RootModel
+
+# Reconciliation API Schema
+#
+# Based on version 0.2 of the schema
+# https://www.w3.org/community/reports/reconciliation/CG-FINAL-specs-0.2-20230410/#service-manifest
+#
class EntityType(Schema):
@@ -13,7 +21,7 @@ class Entity(Schema):
id: str
name: str
description: Optional[str] = None
- type: List[EntityType] = []
+ type: Optional[List[EntityType]] = None
class Property(Schema):
@@ -34,6 +42,7 @@ class UrlSchema(Schema):
class PreviewMetadata(Schema):
+ url: str = None
width: int = 430
height: int = 300
@@ -58,9 +67,14 @@ class DataExtensionProperty(Schema):
settings: DataExtensionPropertySetting = None
+class DataExtensionPropertyProprosal(Schema):
+ service_url: str
+ service_path: str
+
+
class DataExtensionMetadata(Schema):
- propose_properties: bool = False
- property_settings: List[dict]
+ propose_properties: Optional[DataExtensionPropertyProprosal] = None
+ property_settings: List[DataExtensionPropertySetting] = []
class DataExtensionPropertyProposalResponse(Schema):
@@ -81,31 +95,64 @@ class DataExtensionPropertyResponse(Schema):
class DataExtensionQueryResponse(Schema):
meta: List[Entity]
- rows: List[Dict]
+ rows: Dict[str, Dict]
+
+
+class SuggestMetadata(Schema):
+ service_url: str
+ service_path: str
+ flyout_service_url: str = None
+ flyout_service_path: str = None
class ServiceSpecSuggest(Schema):
- entity: bool = False
- property: bool = False
- type: bool = False
+ entity: Optional[SuggestMetadata] = None
+ property: Optional[SuggestMetadata] = None
+ type: Optional[SuggestMetadata] = None
+
+
+class ReconciliationServiceVersions(str, Enum):
+ v0_1 = "0.1"
+ v0_2 = "0.2"
+
+
+class OpenAPISecuritySchemeType(str, Enum):
+ apikey = "apiKey"
+ http = "http"
+ mutualTLS = "mutualTLS"
+ oauth2 = "oauth2"
+ openIdConnect = "openIdConnect"
+
+
+class OpenAPISecuritySchema(Schema):
+ type: OpenAPISecuritySchemeType
+ description: Optional[str] = None
+ name: Optional[str] = None
+ in_: Optional[str] = None
+ scheme: Optional[str] = None
+ bearerFormat: Optional[str] = None
+ flows: Optional[Dict] = None
+ openIdConnectUrl: Optional[str] = None
class ServiceSpec(Schema):
- versions: Optional[List[str]] = ["0.1", "0.2"]
- name: str = "Find that Charity Reconciliation API"
+ versions: Optional[List[ReconciliationServiceVersions]] = [
+ ReconciliationServiceVersions.v0_2
+ ]
+ name: str = "Reconciliation API"
identifierSpace: str = "http://org-id.guide"
schemaSpace: str = "https://schema.org"
+ defaultTypes: List[EntityType] = []
documentation: Optional[str] = None
logo: Optional[str] = None
serviceVersion: Optional[str] = None
- defaultTypes: List[EntityType]
view: UrlSchema
feature_view: Optional[UrlSchema] = None
preview: Optional[PreviewMetadata] = None
suggest: Optional[ServiceSpecSuggest] = None
extend: Optional[DataExtensionMetadata] = None
batchSize: Optional[int] = None
- authentication: Optional[Dict] = None
+ authentication: Optional[OpenAPISecuritySchema] = None
class ReconciliationQuery(Schema):
@@ -121,7 +168,8 @@ class ReconciliationQueryBatch(Schema):
class ReconciliationQueryBatchForm(Schema):
- queries: str
+ queries: str = None
+ extend: str = None
class MatchingFeature(Schema):
@@ -141,11 +189,11 @@ class ReconciliationCandidate(Schema):
class ReconciliationResult(Schema):
- candidates: List[ReconciliationCandidate]
+ result: List[ReconciliationCandidate]
-class ReconciliationResultBatch(Schema):
- results: List[ReconciliationResult]
+class ReconciliationResultBatch(RootModel[Dict[str, Dict]], Schema):
+ root: Dict[str, ReconciliationResult]
class SuggestResult(Schema):
diff --git a/reconcile/query.py b/reconcile/query.py
index d0f55a6a..cfabc5e1 100644
--- a/reconcile/query.py
+++ b/reconcile/query.py
@@ -15,6 +15,7 @@
from ftc.documents import OrganisationGroup
from ftc.models import Organisation
from ftc.models.organisation_classification import OrganisationClassification
+from reconcile.utils import convert_value
with open(os.path.join(os.path.dirname(__file__), "query.json")) as a:
RECONCILE_QUERY = json.load(a)
@@ -152,6 +153,12 @@ def do_extend_query(ids, properties):
if i not in result["rows"]:
result["rows"][i] = {k: None for k in all_fields}
+ # clean up the data
+ result["rows"] = {
+ id: {k: convert_value(v) for k, v in row.items()}
+ for id, row in result["rows"].items()
+ }
+
return result
diff --git a/reconcile/tests/__init__.py b/reconcile/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/reconcile/tests/conftest.py b/reconcile/tests/conftest.py
new file mode 100644
index 00000000..b73f979d
--- /dev/null
+++ b/reconcile/tests/conftest.py
@@ -0,0 +1,25 @@
+import json
+import os
+import warnings
+
+SCHEMA_DIR = os.path.join(os.path.dirname(__file__), "specs")
+SUPPORTED_API_VERSIONS = ["0.2"]
+
+
+def get_schema(
+ filename: str, supported_api_versions: list[str] = SUPPORTED_API_VERSIONS
+) -> dict[str, dict]:
+ schemas = {}
+ for f in os.scandir(SCHEMA_DIR):
+ if not f.is_dir():
+ continue
+ if f.name not in supported_api_versions:
+ continue
+ schema_path = os.path.join(f.path, filename)
+ if os.path.exists(schema_path):
+ with open(schema_path, encoding="utf8") as schema_file:
+ schemas[f.name] = json.load(schema_file)
+ else:
+ msg = f"Schema file {schema_path} not found"
+ warnings.warn(msg)
+ return schemas
diff --git a/reconcile/tests/data/all_reconcile_response.json b/reconcile/tests/data/all_reconcile_response.json
new file mode 100644
index 00000000..01cf9346
--- /dev/null
+++ b/reconcile/tests/data/all_reconcile_response.json
@@ -0,0 +1,467 @@
+{
+ "took": 148,
+ "timed_out": false,
+ "_shards": {
+ "total": 1,
+ "successful": 1,
+ "skipped": 0,
+ "failed": 0
+ },
+ "hits": {
+ "total": {
+ "value": 81,
+ "relation": "eq"
+ },
+ "max_score": 4839660.5,
+ "hits": [
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1006706",
+ "_score": 4839660.5,
+ "_source": {
+ "search_scale": 13.081919991859861,
+ "postalCode": "YO30 2DQ",
+ "active": true,
+ "alternateName": [
+ "SOUTH AND WEST YORKSHIRE PROFICIENCY TEST COMMITTEE",
+ "SOUTH AND WEST YORKSHIRE PROFICIENCY TEST COMMITTEE FOR AGRICULTURE AND HORTICULTURE"
+ ],
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "source": [
+ "ccew"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales"
+ ],
+ "sortname": "yorkshire proficiency test committee",
+ "orgIDs": [
+ "GB-CHC-1006706"
+ ],
+ "org_id": "GB-CHC-1006706",
+ "domain": [
+ "WWW.YPTC.ORG.UK"
+ ],
+ "name": "YORKSHIRE PROFICIENCY TEST COMMITTEE",
+ "ids": [
+ "1006706"
+ ],
+ "locations": [
+ "GB",
+ "E07000164",
+ "E14000993",
+ "E10000023",
+ "E05009675",
+ "E92000001",
+ "E00140637",
+ "E30000294",
+ "E12000003",
+ "E37000039",
+ "E01027619",
+ "E02005760"
+ ],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-SC-SC051892",
+ "_score": 4274882.0,
+ "_source": {
+ "search_scale": 13.524526376648708,
+ "postalCode": "G11 5DZ",
+ "active": true,
+ "alternateName": [
+ "Safe Water for All"
+ ],
+ "dateModified": "2022-09-07T15:35:04.802560+00:00",
+ "source": [
+ "oscr"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-scotland",
+ "registered-society"
+ ],
+ "sortname": "qa test 12",
+ "orgIDs": [
+ "GB-SC-SC051892"
+ ],
+ "org_id": "GB-SC-SC051892",
+ "domain": [
+ "teststandardcharity.com"
+ ],
+ "name": "QA Test 12",
+ "ids": [
+ "SC051892"
+ ],
+ "locations": [],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-SC-SC051908",
+ "_score": 4252229.0,
+ "_source": {
+ "search_scale": 13.452858501957351,
+ "postalCode": "W1U 4DG",
+ "active": true,
+ "alternateName": [],
+ "dateModified": "2022-09-07T15:35:04.805518+00:00",
+ "source": [
+ "oscr"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-scotland",
+ "statutory-corporation-royal-charter-etc"
+ ],
+ "sortname": "qa test 14",
+ "orgIDs": [
+ "GB-SC-SC051908"
+ ],
+ "org_id": "GB-SC-SC051908",
+ "domain": [
+ "testcharity.org.uk"
+ ],
+ "name": "QA Test 14",
+ "ids": [
+ "SC051908"
+ ],
+ "locations": [],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-SC-SC051848",
+ "_score": 3793668.0,
+ "_source": {
+ "search_scale": 12.002099841204238,
+ "postalCode": "DD1 1TB",
+ "active": true,
+ "alternateName": [],
+ "dateModified": "2022-09-07T15:35:04.798522+00:00",
+ "source": [
+ "oscr"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-scotland",
+ "unincorporated-association"
+ ],
+ "sortname": "test charity 41",
+ "orgIDs": [
+ "GB-SC-SC051848"
+ ],
+ "org_id": "GB-SC-SC051848",
+ "domain": [],
+ "name": "Test Charity 41",
+ "ids": [
+ "SC051848"
+ ],
+ "locations": [],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1156829",
+ "_score": 3588501.2,
+ "_source": {
+ "search_scale": 13.744838286283413,
+ "postalCode": "SP10 1LZ",
+ "active": true,
+ "alternateName": [],
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "source": [
+ "ccew",
+ "companies"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales",
+ "registered-company",
+ "incorporated-charity",
+ "company-limited-by-guarantee"
+ ],
+ "sortname": "test valley citizens advice bureau",
+ "orgIDs": [
+ "GB-COH-08933947",
+ "GB-CHC-1156829"
+ ],
+ "org_id": "GB-CHC-1156829",
+ "domain": [
+ "testvalleycab.org.uk"
+ ],
+ "name": "TEST VALLEY CITIZENS ADVICE BUREAU",
+ "ids": [
+ "08933947",
+ "1156829",
+ "8933947"
+ ],
+ "locations": [
+ "E37000010",
+ "GB",
+ "E01023202",
+ "E00117896",
+ "E14000857",
+ "E10000014",
+ "E92000001",
+ "E30000159",
+ "E12000008",
+ "E07000093",
+ "E02004816",
+ "E05012930"
+ ],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-SC-SC051846",
+ "_score": 3516946.5,
+ "_source": {
+ "search_scale": 11.126631103850338,
+ "postalCode": "DD5 1TT",
+ "active": true,
+ "alternateName": [],
+ "dateModified": "2022-09-07T15:35:04.797518+00:00",
+ "source": [
+ "oscr"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-scotland",
+ "scottish-charitable-incorporated-organisation"
+ ],
+ "sortname": "test charity 37",
+ "orgIDs": [
+ "GB-SC-SC051846"
+ ],
+ "org_id": "GB-SC-SC051846",
+ "domain": [],
+ "name": "Test Charity 37",
+ "ids": [
+ "SC051846"
+ ],
+ "locations": [],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1102251",
+ "_score": 3511033.2,
+ "_source": {
+ "search_scale": 9.46674164792014,
+ "postalCode": "SO20 6HA",
+ "active": true,
+ "alternateName": [
+ "TEST VALLEY SCHOOL PARENT TEACHER ASSOCIATION",
+ "TEST VALLEY SCHOOL PTA"
+ ],
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "source": [
+ "ccew"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales"
+ ],
+ "sortname": "test valley parent teacher association",
+ "orgIDs": [
+ "GB-CHC-1102251"
+ ],
+ "org_id": "GB-CHC-1102251",
+ "domain": [
+ "testvalley.hants.sch.uk"
+ ],
+ "name": "TEST VALLEY PARENT TEACHER ASSOCIATION",
+ "ids": [
+ "1102251"
+ ],
+ "locations": [
+ "E37000010",
+ "GB",
+ "E05012098",
+ "E01023167",
+ "E00117714",
+ "E02004822",
+ "E10000014",
+ "E92000001",
+ "E30000159",
+ "E12000008",
+ "E14000901",
+ "E07000093"
+ ],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-514370",
+ "_score": 3496219.2,
+ "_source": {
+ "search_scale": 12.226216252470039,
+ "postalCode": "PE22 7LG",
+ "active": true,
+ "alternateName": [
+ "LINCOLNSHIRE PROFICIENCY TESTS COMMITTEE",
+ "LPTC"
+ ],
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "source": [
+ "ccew"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales"
+ ],
+ "sortname": "lincolnshire proficiency test committee",
+ "orgIDs": [
+ "GB-CHC-514370"
+ ],
+ "org_id": "GB-CHC-514370",
+ "domain": [
+ "lptc.org.uk"
+ ],
+ "name": "LINCOLNSHIRE PROFICIENCY TEST COMMITTEE",
+ "ids": [
+ "514370"
+ ],
+ "locations": [
+ "E01026075",
+ "GB",
+ "E12000004",
+ "E30000174",
+ "E02005441",
+ "E92000001",
+ "E07000137",
+ "E05009877",
+ "E37000014",
+ "E10000018",
+ "E14000798",
+ "E10000024",
+ "E10000019",
+ "E00132346"
+ ],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1173575",
+ "_score": 3366840.5,
+ "_source": {
+ "search_scale": 12.895824531066458,
+ "postalCode": "LD3 8SB",
+ "active": true,
+ "alternateName": [],
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "source": [
+ "ccew",
+ "companies"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales",
+ "charitable-incorporated-organisation",
+ "incorporated-charity",
+ "charitable-incorporated-organisation-foundation",
+ "registered-company"
+ ],
+ "sortname": "british nuclear test veterans association",
+ "orgIDs": [
+ "GB-COH-CE011152",
+ "GB-CHC-1173575"
+ ],
+ "org_id": "GB-CHC-1173575",
+ "domain": [
+ "bntva.com"
+ ],
+ "name": "BRITISH NUCLEAR TEST VETERANS ASSOCIATION",
+ "ids": [
+ "CE011152",
+ "1173575"
+ ],
+ "locations": [
+ "W92000004",
+ "W07000068",
+ "S92000003",
+ "GB",
+ "N92000002",
+ "JE",
+ "GG",
+ "W00002528",
+ "W05000331",
+ "W22000023",
+ "E92000001",
+ "W01000474",
+ "IM",
+ "W06000023",
+ "W02000414"
+ ],
+ "organisationTypePrimary": "registered-charity"
+ }
+ },
+ {
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1090095",
+ "_score": 3189384.8,
+ "_source": {
+ "search_scale": 10.090317329376452,
+ "postalCode": "SP10 3PB",
+ "active": true,
+ "alternateName": [],
+ "dateModified": "2022-07-01T13:29:36.633495+00:00",
+ "source": [
+ "ccew"
+ ],
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales"
+ ],
+ "sortname": "test valley brass",
+ "orgIDs": [
+ "GB-CHC-1090095"
+ ],
+ "org_id": "GB-CHC-1090095",
+ "domain": [
+ "testvalleybrass.co.uk"
+ ],
+ "name": "TEST VALLEY BRASS",
+ "ids": [
+ "1090095"
+ ],
+ "locations": [
+ "E37000010",
+ "GB",
+ "E02004818",
+ "E01023186",
+ "E14000857",
+ "E10000014",
+ "E92000001",
+ "E30000159",
+ "E12000008",
+ "E07000093",
+ "E05012929",
+ "E00117810"
+ ],
+ "organisationTypePrimary": "registered-charity"
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/reconcile/tests/data/all_suggest_response.json b/reconcile/tests/data/all_suggest_response.json
new file mode 100644
index 00000000..8470083c
--- /dev/null
+++ b/reconcile/tests/data/all_suggest_response.json
@@ -0,0 +1,138 @@
+{
+ "took": 689,
+ "timed_out": false,
+ "_shards": {
+ "total": 1,
+ "successful": 1,
+ "skipped": 0,
+ "failed": 0
+ },
+ "hits": {
+ "total": {
+ "value": 0,
+ "relation": "eq"
+ },
+ "max_score": null,
+ "hits": []
+ },
+ "suggest": {
+ "name_complete": [
+ {
+ "text": "Test",
+ "offset": 0,
+ "length": 4,
+ "options": [
+ {
+ "text": "(TES-DA) LIMITED",
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1110225",
+ "_score": 4.0,
+ "_source": {
+ "org_id": "GB-CHC-1110225",
+ "name": "TECHNOLOGICAL EDUCATIONAL AND SCIENTIFIC DEVELOPMENT FOR AFRICA (TES-DA) LIMITED",
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales",
+ "registered-company",
+ "incorporated-charity"
+ ]
+ },
+ "contexts": {
+ "organisationType": [
+ "registered-charity"
+ ]
+ }
+ },
+ {
+ "text": "(TESC)",
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1073410",
+ "_score": 4.0,
+ "_source": {
+ "org_id": "GB-CHC-1073410",
+ "name": "JAMES LEONARD THOMSON COLLEGE",
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales"
+ ]
+ },
+ "contexts": {
+ "organisationType": [
+ "registered-charity"
+ ]
+ }
+ },
+ {
+ "text": "TES",
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-264278",
+ "_score": 4.0,
+ "_source": {
+ "org_id": "GB-CHC-264278",
+ "name": "COUNTIES (FORMERLY COUNTIES EVANGELISTIC WORK)",
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales",
+ "registered-company",
+ "incorporated-charity",
+ "company-limited-by-guarantee"
+ ]
+ },
+ "contexts": {
+ "organisationType": [
+ "registered-charity"
+ ]
+ }
+ },
+ {
+ "text": "TES CROSSROADS CARING FOR CARERS",
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-298228",
+ "_score": 4.0,
+ "_source": {
+ "org_id": "GB-CHC-298228",
+ "name": "T E S CROSSROADS CARE ATTENDANT SCHEME",
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales"
+ ]
+ },
+ "contexts": {
+ "organisationType": [
+ "registered-charity"
+ ]
+ }
+ },
+ {
+ "text": "TESARANGSEE UK",
+ "_index": "full-organisation-load-1166",
+ "_type": "_doc",
+ "_id": "GB-CHC-1160688",
+ "_score": 4.0,
+ "_source": {
+ "org_id": "GB-CHC-1160688",
+ "name": "WAT PA TESARANGSEE UK",
+ "organisationType": [
+ "registered-charity",
+ "registered-charity-england-and-wales",
+ "charitable-incorporated-organisation",
+ "incorporated-charity",
+ "charitable-incorporated-organisation-foundation",
+ "registered-company"
+ ]
+ },
+ "contexts": {
+ "organisationType": [
+ "registered-charity"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/reconcile/tests/data/company_reconcile_response.json b/reconcile/tests/data/company_reconcile_response.json
new file mode 100644
index 00000000..e55d65d2
--- /dev/null
+++ b/reconcile/tests/data/company_reconcile_response.json
@@ -0,0 +1,162 @@
+{
+ "took": 280,
+ "timed_out": false,
+ "_shards": {
+ "total": 1,
+ "successful": 1,
+ "skipped": 0,
+ "failed": 0
+ },
+ "hits": {
+ "total": {
+ "value": 2139,
+ "relation": "eq"
+ },
+ "max_score": 26.07034,
+ "hits": [
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345670",
+ "_score": 26.07034,
+ "_source": {
+ "CompanyNumber": "12345670",
+ "CompanyStatus": "active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "ltd",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY A LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345671",
+ "_score": 20.736366,
+ "_source": {
+ "CompanyNumber": "12345671",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": null,
+ "CompanyCategory": "Charitable Incorporated Organisation",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY B LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345672",
+ "_score": 20.736366,
+ "_source": {
+ "CompanyNumber": "12345672",
+ "CompanyStatus": "active",
+ "RegAddress_PostCode": null,
+ "CompanyCategory": "charitable-incorporated-organisation",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY C LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345673",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345673",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY D LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345674",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345674",
+ "CompanyStatus": "Liquidation",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY E LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345675",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345675",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY F LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345676",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345676",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": [
+ "TEST COMPANY G PREVIOUS LTD",
+ "TEST COMPANY G PREVIOUS 2 LTD"
+ ],
+ "CompanyName": "TEST COMPANY G LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345677",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345677",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY H LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345678",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345678",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY I LTD"
+ }
+ },
+ {
+ "_index": "companies",
+ "_type": "_doc",
+ "_id": "12345679",
+ "_score": 18.16774,
+ "_source": {
+ "CompanyNumber": "12345679",
+ "CompanyStatus": "Active",
+ "RegAddress_PostCode": "SW1A 1AA",
+ "CompanyCategory": "Private Limited Company",
+ "PreviousNames": null,
+ "CompanyName": "TEST COMPANY J LTD"
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/reconcile/tests/data/reconcile_response_empty.json b/reconcile/tests/data/reconcile_response_empty.json
new file mode 100644
index 00000000..8348e230
--- /dev/null
+++ b/reconcile/tests/data/reconcile_response_empty.json
@@ -0,0 +1,18 @@
+{
+ "took": 7,
+ "timed_out": false,
+ "_shards": {
+ "total": 1,
+ "successful": 1,
+ "skipped": 0,
+ "failed": 0
+ },
+ "hits": {
+ "total": {
+ "value": 0,
+ "relation": "eq"
+ },
+ "max_score": null,
+ "hits": []
+ }
+}
\ No newline at end of file
diff --git a/reconcile/tests/specs/0.1/data-extension-property-proposal.json b/reconcile/tests/specs/0.1/data-extension-property-proposal.json
new file mode 100644
index 00000000..3aa2f000
--- /dev/null
+++ b/reconcile/tests/specs/0.1/data-extension-property-proposal.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/draft/schemas/data-extension-property-proposal.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a property proposal endpoint (part of the data extension feature).",
+ "properties": {
+ "properties": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested property"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested property"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ },
+ "type": {
+ "type": "string",
+ "description": "The identifier of the type for which those properties are suggested"
+ },
+ "limit": {
+ "type": "number",
+ "description": "The maximum number of results requested."
+ }
+ },
+ "required": [
+ "properties"
+ ]
+}
diff --git a/reconcile/tests/specs/0.1/data-extension-query.json b/reconcile/tests/specs/0.1/data-extension-query.json
new file mode 100644
index 00000000..e8c0940d
--- /dev/null
+++ b/reconcile/tests/specs/0.1/data-extension-query.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/data-extension-query.json",
+ "type": "object",
+ "description": "This schema validates a data extension query",
+ "properties": {
+ "ids": {
+ "type": "array",
+ "description": "The list of entity identifiers to fetch property values from",
+ "items": {
+ "type": "string"
+ }
+ },
+ "properties": {
+ "type": "array",
+ "description": "The list of properties to fetch, with their optional configuration",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ }
+ },
+ "required": [
+ "ids",
+ "properties"
+ ]
+}
diff --git a/reconcile/tests/specs/0.1/data-extension-response.json b/reconcile/tests/specs/0.1/data-extension-response.json
new file mode 100644
index 00000000..7b057726
--- /dev/null
+++ b/reconcile/tests/specs/0.1/data-extension-response.json
@@ -0,0 +1,131 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/data-extension-response.json",
+ "type": "object",
+ "description": "This schema validates a data extension response",
+ "properties": {
+ "meta": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ },
+ "rows": {
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "str": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "str"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "float": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "float"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "int": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "int"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "date": {
+ "type": "string",
+ "description": "Date and time formatted in ISO format",
+ "pattern": "^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$"
+ }
+ },
+ "required": [
+ "date"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "rows",
+ "meta"
+ ]
+}
diff --git a/reconcile/tests/specs/0.1/manifest.json b/reconcile/tests/specs/0.1/manifest.json
new file mode 100644
index 00000000..919ec079
--- /dev/null
+++ b/reconcile/tests/specs/0.1/manifest.json
@@ -0,0 +1,279 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/manifest.json",
+ "type": "object",
+ "description": "This validates a service manifest, describing the features supported by the endpoint.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "A human-readable name for the service or data source"
+ },
+ "identifierSpace": {
+ "type": "string",
+ "description": "A URI describing the entity identifiers used in this service"
+ },
+ "schemaSpace": {
+ "type": "string",
+ "description": "A URI describing the schema used in this service"
+ },
+ "view": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "A template to transform an entity identifier into the corresponding URI",
+ "pattern": ".*\\{\\{id\\}\\}.*"
+ }
+ },
+ "required": [
+ "url"
+ ]
+ },
+ "defaultTypes": {
+ "type": "array",
+ "description": "A list of default types that are considered good generic choices for reconciliation",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ },
+ "uniqueItems": true
+ },
+ "suggest": {
+ "type": "object",
+ "description": "Settings for the suggest protocol, to auto-complete entities, properties and types",
+ "definitions": {
+ "service_definition": {
+ "type": "object",
+ "properties": {
+ "service_url": {
+ "type": "string"
+ },
+ "service_path": {
+ "type": "string"
+ },
+ "flyout_service_url": {
+ "type": "string"
+ },
+ "flyout_service_path": {
+ "type": "string",
+ "pattern": ".*\\$\\{id\\}.*"
+ }
+ },
+ "required": []
+ }
+ },
+ "properties": {
+ "entity": {
+ "$ref": "#/properties/suggest/definitions/service_definition"
+ },
+ "property": {
+ "$ref": "#/properties/suggest/definitions/service_definition"
+ },
+ "type": {
+ "$ref": "#/properties/suggest/definitions/service_definition"
+ }
+ }
+ },
+ "preview": {
+ "type": "object",
+ "description": "Settings for the preview protocol, for HTML previews of entities",
+ "properties": {
+ "url": {
+ "type": "string",
+ "pattern": ".*\\{\\{id\\}\\}.*",
+ "description": "A URL pattern which transforms the entity ID into a preview URL for it"
+ },
+ "width": {
+ "type": "integer",
+ "description": "The width of the iframe where to include the HTML preview"
+ },
+ "height": {
+ "type": "integer",
+ "description": "The height of the iframe where to include the HTML preview"
+ }
+ },
+ "required": [
+ "url",
+ "width",
+ "height"
+ ]
+ },
+ "extend": {
+ "type": "object",
+ "description": "Settings for the data extension protocol, to fetch property values",
+ "properties": {
+ "propose_properties": {
+ "type": "object",
+ "description": "Location of the endpoint to propose properties to fetch for a given type",
+ "properties": {
+ "service_url": {
+ "type": "string"
+ },
+ "service_path": {
+ "type": "string"
+ }
+ }
+ },
+ "property_settings": {
+ "type": "array",
+ "description": "Definition of the settings configurable by the user when fetching a property",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "Defines a numerical setting on a property",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "number"
+ ]
+ },
+ "default": {
+ "type": "number"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name"
+ ]
+ },
+ {
+ "type": "object",
+ "description": "Defines a string setting on a property",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "text"
+ ]
+ },
+ "default": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name"
+ ]
+ },
+ {
+ "type": "object",
+ "description": "Defines a boolean setting on a property",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "checkbox"
+ ]
+ },
+ "default": {
+ "type": "boolean"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name"
+ ]
+ },
+ {
+ "type": "object",
+ "description": "Defines a setting with a fixed set of choices",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "select"
+ ]
+ },
+ "default": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ },
+ "choices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "value",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name",
+ "choices"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "name",
+ "identifierSpace",
+ "schemaSpace"
+ ]
+}
diff --git a/reconcile/tests/specs/0.1/reconciliation-query-batch.json b/reconcile/tests/specs/0.1/reconciliation-query-batch.json
new file mode 100644
index 00000000..77ca5c6e
--- /dev/null
+++ b/reconcile/tests/specs/0.1/reconciliation-query-batch.json
@@ -0,0 +1,109 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/reconciliation-query.json",
+ "type": "object",
+ "description": "This schema validates the JSON serialization of any reconciliation query batch, i.e. the payload of a GET/POST to a reconciliation endpoint.",
+ "definitions": {
+ "property_value": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "object",
+ "description": "A property value which represents another entity, for instance if it was previously reconciled itself",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ ]
+ }
+ },
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "A string to be matched against the name of the entities"
+ },
+ "type": {
+ "description": "Either a single type identifier or a list of type identifiers",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "limit": {
+ "type": "number",
+ "description": "The maximum number of candidates to return"
+ },
+ "properties": {
+ "type": "array",
+ "description": "An optional list of property mappings to refine the query",
+ "items": {
+ "type": "object",
+ "properties": {
+ "pid": {
+ "type": "string",
+ "description": "The identifier of the property, whose values will be compared to the values supplied"
+ },
+ "v": {
+ "description": "A value (or array of values) to match against the property values associated with the property on each candidate",
+ "oneOf": [
+ {
+ "$ref": "#/definitions/property_value"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/property_value"
+ }
+ }
+ ]
+ }
+ },
+ "required": [
+ "pid",
+ "v"
+ ]
+ }
+ },
+ "type_strict": {
+ "type": "string",
+ "description": "A classification of the type matching strategy when multiple types are supplied",
+ "enum": [
+ "any",
+ "should",
+ "all"
+ ]
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/reconcile/tests/specs/0.1/reconciliation-result-batch.json b/reconcile/tests/specs/0.1/reconciliation-result-batch.json
new file mode 100644
index 00000000..b1ef7b94
--- /dev/null
+++ b/reconcile/tests/specs/0.1/reconciliation-result-batch.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/reconciliation-result-batch.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON serialization of any reconciliation result batch.",
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Entity identifier of the candidate"
+ },
+ "name": {
+ "type": "string",
+ "description": "Entity name of the candidate"
+ },
+ "score": {
+ "type": "number",
+ "description": "Number indicating how likely it is that the candidate matches the query"
+ },
+ "match": {
+ "type": "boolean",
+ "description": "Boolean value indicating whether the candiate is a certain match or not."
+ },
+ "type": {
+ "type": "array",
+ "description": "Types the candidate entity belongs to",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A type can be given by id and name",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ {
+ "type": "string",
+ "description": "Alternatively, if only a string is given, it is treated as the id"
+ }
+ ]
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "score"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+ }
+ }
+}
diff --git a/reconcile/tests/specs/0.1/suggest-entities-response.json b/reconcile/tests/specs/0.1/suggest-entities-response.json
new file mode 100644
index 00000000..00397088
--- /dev/null
+++ b/reconcile/tests/specs/0.1/suggest-entities-response.json
@@ -0,0 +1,62 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/suggest-entities-response.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a suggest service for entities.",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested entity"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested entity"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ },
+ "notable": {
+ "type": "array",
+ "description": "Types the suggest entity belongs to",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A type can be given by id and name",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ {
+ "type": "string",
+ "description": "Alternatively, if only a string is given, it is treated as the id"
+ }
+ ]
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+}
diff --git a/reconcile/tests/specs/0.1/suggest-properties-response.json b/reconcile/tests/specs/0.1/suggest-properties-response.json
new file mode 100644
index 00000000..cce9cdb6
--- /dev/null
+++ b/reconcile/tests/specs/0.1/suggest-properties-response.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/suggest-properties-response.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a suggest service for properties.",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested property"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested property"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+}
diff --git a/reconcile/tests/specs/0.1/suggest-types-response.json b/reconcile/tests/specs/0.1/suggest-types-response.json
new file mode 100644
index 00000000..9044093a
--- /dev/null
+++ b/reconcile/tests/specs/0.1/suggest-types-response.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.1/schemas/suggest-types-response.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a suggest service for types.",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested type"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested type"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/data-extension-property-proposal.json b/reconcile/tests/specs/0.2/data-extension-property-proposal.json
new file mode 100644
index 00000000..3aa2f000
--- /dev/null
+++ b/reconcile/tests/specs/0.2/data-extension-property-proposal.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/draft/schemas/data-extension-property-proposal.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a property proposal endpoint (part of the data extension feature).",
+ "properties": {
+ "properties": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested property"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested property"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ },
+ "type": {
+ "type": "string",
+ "description": "The identifier of the type for which those properties are suggested"
+ },
+ "limit": {
+ "type": "number",
+ "description": "The maximum number of results requested."
+ }
+ },
+ "required": [
+ "properties"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/data-extension-query.json b/reconcile/tests/specs/0.2/data-extension-query.json
new file mode 100644
index 00000000..82480251
--- /dev/null
+++ b/reconcile/tests/specs/0.2/data-extension-query.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/data-extension-query.json",
+ "type": "object",
+ "description": "This schema validates a data extension query",
+ "properties": {
+ "ids": {
+ "type": "array",
+ "description": "The list of entity identifiers to fetch property values from",
+ "items": {
+ "type": "string"
+ }
+ },
+ "properties": {
+ "type": "array",
+ "description": "The list of properties to fetch, with their optional configuration",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "settings": {
+ "type": "object"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ }
+ },
+ "required": [
+ "ids",
+ "properties"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/data-extension-response.json b/reconcile/tests/specs/0.2/data-extension-response.json
new file mode 100644
index 00000000..c37e7586
--- /dev/null
+++ b/reconcile/tests/specs/0.2/data-extension-response.json
@@ -0,0 +1,139 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/data-extension-response.json",
+ "type": "object",
+ "description": "This schema validates a data extension response",
+ "properties": {
+ "meta": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ "service": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https?://"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ },
+ "rows": {
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "str": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "str"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "float": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "float"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "int": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "int"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "date": {
+ "type": "string",
+ "description": "Date and time formatted in ISO format",
+ "pattern": "^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$"
+ }
+ },
+ "required": [
+ "date"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "rows",
+ "meta"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/manifest.json b/reconcile/tests/specs/0.2/manifest.json
new file mode 100644
index 00000000..75f7d3da
--- /dev/null
+++ b/reconcile/tests/specs/0.2/manifest.json
@@ -0,0 +1,561 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/manifest.json",
+ "type": "object",
+ "description": "This validates a service manifest, describing the features supported by the endpoint.",
+ "properties": {
+ "versions": {
+ "type": "array",
+ "description": "The list of API versions supported by this service.",
+ "items": {
+ "type": "string"
+ },
+ "contains": {
+ "enum": [
+ "0.2"
+ ]
+ }
+ },
+ "name": {
+ "type": "string",
+ "description": "A human-readable name for the service or data source"
+ },
+ "identifierSpace": {
+ "type": "string",
+ "description": "A URI describing the entity identifiers used in this service"
+ },
+ "schemaSpace": {
+ "type": "string",
+ "description": "A URI describing the schema used in this service"
+ },
+ "documentation": {
+ "type": "string",
+ "description": "A URI which hosts documentation about this service"
+ },
+ "serviceVersion": {
+ "type": "string",
+ "description": "A string representing the version of the software which exposes this service"
+ },
+ "logo": {
+ "type": "string",
+ "description": "A URI to a square image which can be used as logo for this service"
+ },
+ "authentication": {
+ "$ref": "#/definitions/securityDefinitions/additionalProperties"
+ },
+ "view": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "A template to transform an entity identifier into the corresponding URI",
+ "pattern": ".*\\{\\{id\\}\\}.*"
+ }
+ },
+ "required": [
+ "url"
+ ]
+ },
+ "feature_view": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "A template to transform a matching feature identifier into the corresponding URI",
+ "pattern": ".*\\{\\{id\\}\\}.*"
+ }
+ },
+ "required": [
+ "url"
+ ]
+ },
+ "defaultTypes": {
+ "type": "array",
+ "description": "A list of default types that are considered good generic choices for reconciliation",
+ "items": {
+ "$ref": "type.json"
+ },
+ "uniqueItems": true
+ },
+ "suggest": {
+ "type": "object",
+ "description": "Settings for the suggest protocol, to auto-complete entities, properties and types",
+ "definitions": {
+ "service_definition": {
+ "type": "object",
+ "properties": {
+ "service_url": {
+ "type": "string"
+ },
+ "service_path": {
+ "type": "string"
+ },
+ "flyout_service_url": {
+ "type": "string"
+ },
+ "flyout_service_path": {
+ "type": "string",
+ "pattern": ".*\\$\\{id\\}.*"
+ }
+ },
+ "required": []
+ }
+ },
+ "properties": {
+ "entity": {
+ "$ref": "#/properties/suggest/definitions/service_definition"
+ },
+ "property": {
+ "$ref": "#/properties/suggest/definitions/service_definition"
+ },
+ "type": {
+ "$ref": "#/properties/suggest/definitions/service_definition"
+ }
+ }
+ },
+ "preview": {
+ "type": "object",
+ "description": "Settings for the preview protocol, for HTML previews of entities",
+ "properties": {
+ "url": {
+ "type": "string",
+ "pattern": ".*\\{\\{id\\}\\}.*",
+ "description": "A URL pattern which transforms the entity ID into a preview URL for it"
+ },
+ "width": {
+ "type": "integer",
+ "description": "The width of the iframe where to include the HTML preview"
+ },
+ "height": {
+ "type": "integer",
+ "description": "The height of the iframe where to include the HTML preview"
+ }
+ },
+ "required": [
+ "url",
+ "width",
+ "height"
+ ]
+ },
+ "extend": {
+ "type": "object",
+ "description": "Settings for the data extension protocol, to fetch property values",
+ "properties": {
+ "propose_properties": {
+ "type": "object",
+ "description": "Location of the endpoint to propose properties to fetch for a given type",
+ "properties": {
+ "service_url": {
+ "type": "string"
+ },
+ "service_path": {
+ "type": "string"
+ }
+ }
+ },
+ "property_settings": {
+ "type": "array",
+ "description": "Definition of the settings configurable by the user when fetching a property",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "Defines a numerical setting on a property",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "number"
+ ]
+ },
+ "default": {
+ "type": "number"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name"
+ ]
+ },
+ {
+ "type": "object",
+ "description": "Defines a string setting on a property",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "text"
+ ]
+ },
+ "default": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name"
+ ]
+ },
+ {
+ "type": "object",
+ "description": "Defines a boolean setting on a property",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "checkbox"
+ ]
+ },
+ "default": {
+ "type": "boolean"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name"
+ ]
+ },
+ {
+ "type": "object",
+ "description": "Defines a setting with a fixed set of choices",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "select"
+ ]
+ },
+ "default": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "help_text": {
+ "type": "string"
+ },
+ "choices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "value",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "type",
+ "label",
+ "name",
+ "choices"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "vendorExtension": {
+ "description": "Any property starting with x- is valid.",
+ "additionalProperties": true,
+ "additionalItems": true
+ },
+ "oauth2Scopes": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "securityDefinitions": {
+ "type": "object",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/basicAuthenticationSecurity"
+ },
+ {
+ "$ref": "#/definitions/apiKeySecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2ImplicitSecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2PasswordSecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2ApplicationSecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2AccessCodeSecurity"
+ }
+ ]
+ }
+ },
+ "basicAuthenticationSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "basic"
+ ]
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "apiKeySecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "name",
+ "in"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "apiKey"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "in": {
+ "type": "string",
+ "enum": [
+ "header",
+ "query"
+ ]
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2ImplicitSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "authorizationUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "implicit"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "authorizationUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2PasswordSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "tokenUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "password"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "tokenUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2ApplicationSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "tokenUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "application"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "tokenUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2AccessCodeSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "authorizationUrl",
+ "tokenUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "accessCode"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "authorizationUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "tokenUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ }
+ },
+ "required": [
+ "versions",
+ "name",
+ "identifierSpace",
+ "schemaSpace"
+ ]
+}
\ No newline at end of file
diff --git a/reconcile/tests/specs/0.2/openapi.json b/reconcile/tests/specs/0.2/openapi.json
new file mode 100644
index 00000000..5a8e270a
--- /dev/null
+++ b/reconcile/tests/specs/0.2/openapi.json
@@ -0,0 +1,1577 @@
+{
+ "title": "A JSON Schema for Swagger 2.0 API.",
+ "$id": "http://swagger.io/v2/schema.json#",
+ "$schema": "http://json-schema.org/schema#",
+ "type": "object",
+ "required": [
+ "swagger",
+ "info",
+ "paths"
+ ],
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "swagger": {
+ "type": "string",
+ "enum": [
+ "2.0"
+ ],
+ "description": "The Swagger version of this document."
+ },
+ "info": {
+ "$ref": "#/definitions/info"
+ },
+ "host": {
+ "type": "string",
+ "pattern": "^[^{}/ :\\\\]+(?::\\d+)?$",
+ "description": "The host (name or ip) of the API. Example: 'swagger.io'"
+ },
+ "basePath": {
+ "type": "string",
+ "pattern": "^/",
+ "description": "The base path to the API. Example: '/api'."
+ },
+ "schemes": {
+ "$ref": "#/definitions/schemesList"
+ },
+ "consumes": {
+ "description": "A list of MIME types accepted by the API.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/mediaTypeList"
+ }
+ ]
+ },
+ "produces": {
+ "description": "A list of MIME types the API can produce.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/mediaTypeList"
+ }
+ ]
+ },
+ "paths": {
+ "$ref": "#/definitions/paths"
+ },
+ "definitions": {
+ "$ref": "#/definitions/definitions"
+ },
+ "parameters": {
+ "$ref": "#/definitions/parameterDefinitions"
+ },
+ "responses": {
+ "$ref": "#/definitions/responseDefinitions"
+ },
+ "security": {
+ "$ref": "#/definitions/security"
+ },
+ "securityDefinitions": {
+ "$ref": "#/definitions/securityDefinitions"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/tag"
+ },
+ "uniqueItems": true
+ },
+ "externalDocs": {
+ "$ref": "#/definitions/externalDocs"
+ }
+ },
+ "definitions": {
+ "info": {
+ "type": "object",
+ "description": "General information about the API.",
+ "required": [
+ "version",
+ "title"
+ ],
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "title": {
+ "type": "string",
+ "description": "A unique and precise title of the API."
+ },
+ "version": {
+ "type": "string",
+ "description": "A semantic version number of the API."
+ },
+ "description": {
+ "type": "string",
+ "description": "A longer description of the API. Should be different from the title. GitHub Flavored Markdown is allowed."
+ },
+ "termsOfService": {
+ "type": "string",
+ "description": "The terms of service for the API."
+ },
+ "contact": {
+ "$ref": "#/definitions/contact"
+ },
+ "license": {
+ "$ref": "#/definitions/license"
+ }
+ }
+ },
+ "contact": {
+ "type": "object",
+ "description": "Contact information for the owners of the API.",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The identifying name of the contact person/organization."
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL pointing to the contact information.",
+ "format": "uri"
+ },
+ "email": {
+ "type": "string",
+ "description": "The email address of the contact person/organization.",
+ "format": "email"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "license": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the license type. It's encouraged to use an OSI compatible license."
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL pointing to the license.",
+ "format": "uri"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "paths": {
+ "type": "object",
+ "description": "Relative paths to the individual endpoints. They must be relative to the 'basePath'.",
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ },
+ "^/": {
+ "$ref": "#/definitions/pathItem"
+ }
+ },
+ "additionalProperties": false
+ },
+ "definitions": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/schema"
+ },
+ "description": "One or more JSON objects describing the schemas being consumed and produced by the API."
+ },
+ "parameterDefinitions": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/parameter"
+ },
+ "description": "One or more JSON representations for parameters"
+ },
+ "responseDefinitions": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/response"
+ },
+ "description": "One or more JSON representations for responses"
+ },
+ "externalDocs": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "information about external documentation",
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "examples": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "mimeType": {
+ "type": "string",
+ "description": "The MIME type of the HTTP message."
+ },
+ "operation": {
+ "type": "object",
+ "required": [
+ "responses"
+ ],
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true
+ },
+ "summary": {
+ "type": "string",
+ "description": "A brief summary of the operation."
+ },
+ "description": {
+ "type": "string",
+ "description": "A longer description of the operation, GitHub Flavored Markdown is allowed."
+ },
+ "externalDocs": {
+ "$ref": "#/definitions/externalDocs"
+ },
+ "operationId": {
+ "type": "string",
+ "description": "A unique identifier of the operation."
+ },
+ "produces": {
+ "description": "A list of MIME types the API can produce.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/mediaTypeList"
+ }
+ ]
+ },
+ "consumes": {
+ "description": "A list of MIME types the API can consume.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/mediaTypeList"
+ }
+ ]
+ },
+ "parameters": {
+ "$ref": "#/definitions/parametersList"
+ },
+ "responses": {
+ "$ref": "#/definitions/responses"
+ },
+ "schemes": {
+ "$ref": "#/definitions/schemesList"
+ },
+ "deprecated": {
+ "type": "boolean",
+ "default": false
+ },
+ "security": {
+ "$ref": "#/definitions/security"
+ }
+ }
+ },
+ "pathItem": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "$ref": {
+ "type": "string"
+ },
+ "get": {
+ "$ref": "#/definitions/operation"
+ },
+ "put": {
+ "$ref": "#/definitions/operation"
+ },
+ "post": {
+ "$ref": "#/definitions/operation"
+ },
+ "delete": {
+ "$ref": "#/definitions/operation"
+ },
+ "options": {
+ "$ref": "#/definitions/operation"
+ },
+ "head": {
+ "$ref": "#/definitions/operation"
+ },
+ "patch": {
+ "$ref": "#/definitions/operation"
+ },
+ "parameters": {
+ "$ref": "#/definitions/parametersList"
+ }
+ }
+ },
+ "responses": {
+ "type": "object",
+ "description": "Response objects names can either be any valid HTTP status code or 'default'.",
+ "minProperties": 1,
+ "additionalProperties": false,
+ "patternProperties": {
+ "^([0-9]{3})$|^(default)$": {
+ "$ref": "#/definitions/responseValue"
+ },
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "not": {
+ "type": "object",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ }
+ },
+ "responseValue": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/response"
+ },
+ {
+ "$ref": "#/definitions/jsonReference"
+ }
+ ]
+ },
+ "response": {
+ "type": "object",
+ "required": [
+ "description"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "schema": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/schema"
+ },
+ {
+ "$ref": "#/definitions/fileSchema"
+ }
+ ]
+ },
+ "headers": {
+ "$ref": "#/definitions/headers"
+ },
+ "examples": {
+ "$ref": "#/definitions/examples"
+ }
+ },
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "headers": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/header"
+ }
+ },
+ "header": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "integer",
+ "boolean",
+ "array"
+ ]
+ },
+ "format": {
+ "type": "string"
+ },
+ "items": {
+ "$ref": "#/definitions/primitivesItems"
+ },
+ "collectionFormat": {
+ "$ref": "#/definitions/collectionFormat"
+ },
+ "default": {
+ "$ref": "#/definitions/default"
+ },
+ "maximum": {
+ "$ref": "#/definitions/maximum"
+ },
+ "exclusiveMaximum": {
+ "$ref": "#/definitions/exclusiveMaximum"
+ },
+ "minimum": {
+ "$ref": "#/definitions/minimum"
+ },
+ "exclusiveMinimum": {
+ "$ref": "#/definitions/exclusiveMinimum"
+ },
+ "maxLength": {
+ "$ref": "#/definitions/maxLength"
+ },
+ "minLength": {
+ "$ref": "#/definitions/minLength"
+ },
+ "pattern": {
+ "$ref": "#/definitions/pattern"
+ },
+ "maxItems": {
+ "$ref": "#/definitions/maxItems"
+ },
+ "minItems": {
+ "$ref": "#/definitions/minItems"
+ },
+ "uniqueItems": {
+ "$ref": "#/definitions/uniqueItems"
+ },
+ "enum": {
+ "$ref": "#/definitions/enum"
+ },
+ "multipleOf": {
+ "$ref": "#/definitions/multipleOf"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "vendorExtension": {
+ "description": "Any property starting with x- is valid.",
+ "additionalProperties": true
+ },
+ "bodyParameter": {
+ "type": "object",
+ "required": [
+ "name",
+ "in",
+ "schema"
+ ],
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "description": {
+ "type": "string",
+ "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the parameter."
+ },
+ "in": {
+ "type": "string",
+ "description": "Determines the location of the parameter.",
+ "enum": [
+ "body"
+ ]
+ },
+ "required": {
+ "type": "boolean",
+ "description": "Determines whether or not this parameter is required or optional.",
+ "default": false
+ },
+ "schema": {
+ "$ref": "#/definitions/schema"
+ }
+ },
+ "additionalProperties": false
+ },
+ "headerParameterSubSchema": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "required": {
+ "type": "boolean",
+ "description": "Determines whether or not this parameter is required or optional.",
+ "default": false
+ },
+ "in": {
+ "type": "string",
+ "description": "Determines the location of the parameter.",
+ "enum": [
+ "header"
+ ]
+ },
+ "description": {
+ "type": "string",
+ "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the parameter."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "boolean",
+ "integer",
+ "array"
+ ]
+ },
+ "format": {
+ "type": "string"
+ },
+ "items": {
+ "$ref": "#/definitions/primitivesItems"
+ },
+ "collectionFormat": {
+ "$ref": "#/definitions/collectionFormat"
+ },
+ "default": {
+ "$ref": "#/definitions/default"
+ },
+ "maximum": {
+ "$ref": "#/definitions/maximum"
+ },
+ "exclusiveMaximum": {
+ "$ref": "#/definitions/exclusiveMaximum"
+ },
+ "minimum": {
+ "$ref": "#/definitions/minimum"
+ },
+ "exclusiveMinimum": {
+ "$ref": "#/definitions/exclusiveMinimum"
+ },
+ "maxLength": {
+ "$ref": "#/definitions/maxLength"
+ },
+ "minLength": {
+ "$ref": "#/definitions/minLength"
+ },
+ "pattern": {
+ "$ref": "#/definitions/pattern"
+ },
+ "maxItems": {
+ "$ref": "#/definitions/maxItems"
+ },
+ "minItems": {
+ "$ref": "#/definitions/minItems"
+ },
+ "uniqueItems": {
+ "$ref": "#/definitions/uniqueItems"
+ },
+ "enum": {
+ "$ref": "#/definitions/enum"
+ },
+ "multipleOf": {
+ "$ref": "#/definitions/multipleOf"
+ }
+ }
+ },
+ "queryParameterSubSchema": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "required": {
+ "type": "boolean",
+ "description": "Determines whether or not this parameter is required or optional.",
+ "default": false
+ },
+ "in": {
+ "type": "string",
+ "description": "Determines the location of the parameter.",
+ "enum": [
+ "query"
+ ]
+ },
+ "description": {
+ "type": "string",
+ "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the parameter."
+ },
+ "allowEmptyValue": {
+ "type": "boolean",
+ "default": false,
+ "description": "allows sending a parameter by name only or with an empty value."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "boolean",
+ "integer",
+ "array"
+ ]
+ },
+ "format": {
+ "type": "string"
+ },
+ "items": {
+ "$ref": "#/definitions/primitivesItems"
+ },
+ "collectionFormat": {
+ "$ref": "#/definitions/collectionFormatWithMulti"
+ },
+ "default": {
+ "$ref": "#/definitions/default"
+ },
+ "maximum": {
+ "$ref": "#/definitions/maximum"
+ },
+ "exclusiveMaximum": {
+ "$ref": "#/definitions/exclusiveMaximum"
+ },
+ "minimum": {
+ "$ref": "#/definitions/minimum"
+ },
+ "exclusiveMinimum": {
+ "$ref": "#/definitions/exclusiveMinimum"
+ },
+ "maxLength": {
+ "$ref": "#/definitions/maxLength"
+ },
+ "minLength": {
+ "$ref": "#/definitions/minLength"
+ },
+ "pattern": {
+ "$ref": "#/definitions/pattern"
+ },
+ "maxItems": {
+ "$ref": "#/definitions/maxItems"
+ },
+ "minItems": {
+ "$ref": "#/definitions/minItems"
+ },
+ "uniqueItems": {
+ "$ref": "#/definitions/uniqueItems"
+ },
+ "enum": {
+ "$ref": "#/definitions/enum"
+ },
+ "multipleOf": {
+ "$ref": "#/definitions/multipleOf"
+ }
+ }
+ },
+ "formDataParameterSubSchema": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "required": {
+ "type": "boolean",
+ "description": "Determines whether or not this parameter is required or optional.",
+ "default": false
+ },
+ "in": {
+ "type": "string",
+ "description": "Determines the location of the parameter.",
+ "enum": [
+ "formData"
+ ]
+ },
+ "description": {
+ "type": "string",
+ "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the parameter."
+ },
+ "allowEmptyValue": {
+ "type": "boolean",
+ "default": false,
+ "description": "allows sending a parameter by name only or with an empty value."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "boolean",
+ "integer",
+ "array",
+ "file"
+ ]
+ },
+ "format": {
+ "type": "string"
+ },
+ "items": {
+ "$ref": "#/definitions/primitivesItems"
+ },
+ "collectionFormat": {
+ "$ref": "#/definitions/collectionFormatWithMulti"
+ },
+ "default": {
+ "$ref": "#/definitions/default"
+ },
+ "maximum": {
+ "$ref": "#/definitions/maximum"
+ },
+ "exclusiveMaximum": {
+ "$ref": "#/definitions/exclusiveMaximum"
+ },
+ "minimum": {
+ "$ref": "#/definitions/minimum"
+ },
+ "exclusiveMinimum": {
+ "$ref": "#/definitions/exclusiveMinimum"
+ },
+ "maxLength": {
+ "$ref": "#/definitions/maxLength"
+ },
+ "minLength": {
+ "$ref": "#/definitions/minLength"
+ },
+ "pattern": {
+ "$ref": "#/definitions/pattern"
+ },
+ "maxItems": {
+ "$ref": "#/definitions/maxItems"
+ },
+ "minItems": {
+ "$ref": "#/definitions/minItems"
+ },
+ "uniqueItems": {
+ "$ref": "#/definitions/uniqueItems"
+ },
+ "enum": {
+ "$ref": "#/definitions/enum"
+ },
+ "multipleOf": {
+ "$ref": "#/definitions/multipleOf"
+ }
+ }
+ },
+ "pathParameterSubSchema": {
+ "additionalProperties": false,
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "required": [
+ "required"
+ ],
+ "properties": {
+ "required": {
+ "type": "boolean",
+ "enum": [
+ true
+ ],
+ "description": "Determines whether or not this parameter is required or optional."
+ },
+ "in": {
+ "type": "string",
+ "description": "Determines the location of the parameter.",
+ "enum": [
+ "path"
+ ]
+ },
+ "description": {
+ "type": "string",
+ "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed."
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the parameter."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "boolean",
+ "integer",
+ "array"
+ ]
+ },
+ "format": {
+ "type": "string"
+ },
+ "items": {
+ "$ref": "#/definitions/primitivesItems"
+ },
+ "collectionFormat": {
+ "$ref": "#/definitions/collectionFormat"
+ },
+ "default": {
+ "$ref": "#/definitions/default"
+ },
+ "maximum": {
+ "$ref": "#/definitions/maximum"
+ },
+ "exclusiveMaximum": {
+ "$ref": "#/definitions/exclusiveMaximum"
+ },
+ "minimum": {
+ "$ref": "#/definitions/minimum"
+ },
+ "exclusiveMinimum": {
+ "$ref": "#/definitions/exclusiveMinimum"
+ },
+ "maxLength": {
+ "$ref": "#/definitions/maxLength"
+ },
+ "minLength": {
+ "$ref": "#/definitions/minLength"
+ },
+ "pattern": {
+ "$ref": "#/definitions/pattern"
+ },
+ "maxItems": {
+ "$ref": "#/definitions/maxItems"
+ },
+ "minItems": {
+ "$ref": "#/definitions/minItems"
+ },
+ "uniqueItems": {
+ "$ref": "#/definitions/uniqueItems"
+ },
+ "enum": {
+ "$ref": "#/definitions/enum"
+ },
+ "multipleOf": {
+ "$ref": "#/definitions/multipleOf"
+ }
+ }
+ },
+ "nonBodyParameter": {
+ "type": "object",
+ "required": [
+ "name",
+ "in",
+ "type"
+ ],
+ "oneOf": [
+ {
+ "$ref": "#/definitions/headerParameterSubSchema"
+ },
+ {
+ "$ref": "#/definitions/formDataParameterSubSchema"
+ },
+ {
+ "$ref": "#/definitions/queryParameterSubSchema"
+ },
+ {
+ "$ref": "#/definitions/pathParameterSubSchema"
+ }
+ ]
+ },
+ "parameter": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/bodyParameter"
+ },
+ {
+ "$ref": "#/definitions/nonBodyParameter"
+ }
+ ]
+ },
+ "schema": {
+ "type": "object",
+ "description": "A deterministic version of a JSON Schema object.",
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "properties": {
+ "$ref": {
+ "type": "string"
+ },
+ "format": {
+ "type": "string"
+ },
+ "title": {
+ },
+ "description": {
+ },
+ "default": {
+ },
+ "multipleOf": {
+ },
+ "maximum": {
+ },
+ "exclusiveMaximum": {
+ },
+ "minimum": {
+ },
+ "exclusiveMinimum": {
+ },
+ "maxLength": {
+ "type": "integer"
+ },
+ "minLength": {
+ "type": "integer"
+ },
+ "pattern": {
+ },
+ "maxItems": {
+ "type": "integer"
+ },
+ "minItems": {
+ "type": "integer"
+ },
+ "uniqueItems": {
+ },
+ "maxProperties": {
+ "type": "integer"
+ },
+ "minProperties": {
+ "type": "integer"
+ },
+ "required": {
+ },
+ "enum": {
+ },
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/schema"
+ },
+ {
+ "type": "boolean"
+ }
+ ],
+ "default": {}
+ },
+ "type": {
+ },
+ "items": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/schema"
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "#/definitions/schema"
+ }
+ }
+ ],
+ "default": {}
+ },
+ "allOf": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "#/definitions/schema"
+ }
+ },
+ "properties": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/definitions/schema"
+ },
+ "default": {}
+ },
+ "discriminator": {
+ "type": "string"
+ },
+ "readOnly": {
+ "type": "boolean",
+ "default": false
+ },
+ "xml": {
+ "$ref": "#/definitions/xml"
+ },
+ "externalDocs": {
+ "$ref": "#/definitions/externalDocs"
+ },
+ "example": {}
+ },
+ "additionalProperties": false
+ },
+ "fileSchema": {
+ "type": "object",
+ "description": "A deterministic version of a JSON Schema object.",
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ },
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "format": {
+ "type": "string"
+ },
+ "title": {
+ },
+ "description": {
+ },
+ "default": {
+ },
+ "required": {
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "file"
+ ]
+ },
+ "readOnly": {
+ "type": "boolean",
+ "default": false
+ },
+ "externalDocs": {
+ "$ref": "#/definitions/externalDocs"
+ },
+ "example": {}
+ },
+ "additionalProperties": false
+ },
+ "primitivesItems": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "string",
+ "number",
+ "integer",
+ "boolean",
+ "array"
+ ]
+ },
+ "format": {
+ "type": "string"
+ },
+ "items": {
+ "$ref": "#/definitions/primitivesItems"
+ },
+ "collectionFormat": {
+ "$ref": "#/definitions/collectionFormat"
+ },
+ "default": {
+ "$ref": "#/definitions/default"
+ },
+ "maximum": {
+ "$ref": "#/definitions/maximum"
+ },
+ "exclusiveMaximum": {
+ "$ref": "#/definitions/exclusiveMaximum"
+ },
+ "minimum": {
+ "$ref": "#/definitions/minimum"
+ },
+ "exclusiveMinimum": {
+ "$ref": "#/definitions/exclusiveMinimum"
+ },
+ "maxLength": {
+ "$ref": "#/definitions/maxLength"
+ },
+ "minLength": {
+ "$ref": "#/definitions/minLength"
+ },
+ "pattern": {
+ "$ref": "#/definitions/pattern"
+ },
+ "maxItems": {
+ "$ref": "#/definitions/maxItems"
+ },
+ "minItems": {
+ "$ref": "#/definitions/minItems"
+ },
+ "uniqueItems": {
+ "$ref": "#/definitions/uniqueItems"
+ },
+ "enum": {
+ "$ref": "#/definitions/enum"
+ },
+ "multipleOf": {
+ "$ref": "#/definitions/multipleOf"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "security": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/securityRequirement"
+ },
+ "uniqueItems": true
+ },
+ "securityRequirement": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true
+ }
+ },
+ "xml": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "namespace": {
+ "type": "string"
+ },
+ "prefix": {
+ "type": "string"
+ },
+ "attribute": {
+ "type": "boolean",
+ "default": false
+ },
+ "wrapped": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "tag": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "externalDocs": {
+ "$ref": "#/definitions/externalDocs"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "securityDefinitions": {
+ "type": "object",
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/basicAuthenticationSecurity"
+ },
+ {
+ "$ref": "#/definitions/apiKeySecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2ImplicitSecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2PasswordSecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2ApplicationSecurity"
+ },
+ {
+ "$ref": "#/definitions/oauth2AccessCodeSecurity"
+ }
+ ]
+ }
+ },
+ "basicAuthenticationSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "basic"
+ ]
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "apiKeySecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "name",
+ "in"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "apiKey"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "in": {
+ "type": "string",
+ "enum": [
+ "header",
+ "query"
+ ]
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2ImplicitSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "authorizationUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "implicit"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "authorizationUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2PasswordSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "tokenUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "password"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "tokenUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2ApplicationSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "tokenUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "application"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "tokenUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2AccessCodeSecurity": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "type",
+ "flow",
+ "authorizationUrl",
+ "tokenUrl"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "oauth2"
+ ]
+ },
+ "flow": {
+ "type": "string",
+ "enum": [
+ "accessCode"
+ ]
+ },
+ "scopes": {
+ "$ref": "#/definitions/oauth2Scopes"
+ },
+ "authorizationUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "tokenUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "^x-": {
+ "$ref": "#/definitions/vendorExtension"
+ }
+ }
+ },
+ "oauth2Scopes": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "mediaTypeList": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/mimeType"
+ },
+ "uniqueItems": true
+ },
+ "parametersList": {
+ "type": "array",
+ "description": "The parameters needed to send a valid API call.",
+ "items": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/parameter"
+ },
+ {
+ "$ref": "#/definitions/jsonReference"
+ }
+ ]
+ },
+ "uniqueItems": true
+ },
+ "schemesList": {
+ "type": "array",
+ "description": "The transfer protocol of the API.",
+ "items": {
+ "type": "string",
+ "enum": [
+ "http",
+ "https",
+ "ws",
+ "wss"
+ ]
+ },
+ "uniqueItems": true
+ },
+ "collectionFormat": {
+ "type": "string",
+ "enum": [
+ "csv",
+ "ssv",
+ "tsv",
+ "pipes"
+ ],
+ "default": "csv"
+ },
+ "collectionFormatWithMulti": {
+ "type": "string",
+ "enum": [
+ "csv",
+ "ssv",
+ "tsv",
+ "pipes",
+ "multi"
+ ],
+ "default": "csv"
+ },
+ "title": {
+ },
+ "description": {
+ },
+ "default": {
+ },
+ "multipleOf": {
+ },
+ "maximum": {
+ },
+ "exclusiveMaximum": {
+ },
+ "minimum": {
+ },
+ "exclusiveMinimum": {
+ },
+ "maxLength": {
+ "type": "integer"
+ },
+ "minLength": {
+ "type": "integer"
+ },
+ "pattern": {
+ },
+ "maxItems": {
+ "type": "integer"
+ },
+ "minItems": {
+ "type": "integer"
+ },
+ "uniqueItems": {
+ },
+ "enum": {
+ },
+ "jsonReference": {
+ "type": "object",
+ "required": [
+ "$ref"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "$ref": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
diff --git a/reconcile/tests/specs/0.2/reconciliation-query-batch.json b/reconcile/tests/specs/0.2/reconciliation-query-batch.json
new file mode 100644
index 00000000..2008ae03
--- /dev/null
+++ b/reconcile/tests/specs/0.2/reconciliation-query-batch.json
@@ -0,0 +1,124 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/reconciliation-query.json",
+ "type": "object",
+ "description": "This schema validates the JSON serialization of any reconciliation query batch, i.e. the payload of a GET/POST to a reconciliation endpoint.",
+ "definitions": {
+ "property_value": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "object",
+ "description": "A property value which represents another entity, for instance if it was previously reconciled itself",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ ]
+ }
+ },
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "A string to be matched against the name of the entities"
+ },
+ "type": {
+ "description": "Either a single type identifier or a list of type identifiers",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "limit": {
+ "type": "number",
+ "description": "The maximum number of candidates to return"
+ },
+ "properties": {
+ "type": "array",
+ "description": "An optional list of property mappings to refine the query",
+ "items": {
+ "type": "object",
+ "properties": {
+ "pid": {
+ "type": "string",
+ "description": "The identifier of the property, whose values will be compared to the values supplied"
+ },
+ "v": {
+ "description": "A value (or array of values) to match against the property values associated with the property on each candidate",
+ "oneOf": [
+ {
+ "$ref": "#/definitions/property_value"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/property_value"
+ }
+ }
+ ]
+ }
+ },
+ "required": [
+ "pid",
+ "v"
+ ]
+ }
+ },
+ "type_strict": {
+ "type": "string",
+ "description": "A classification of the type matching strategy when multiple types are supplied",
+ "enum": [
+ "any",
+ "should",
+ "all"
+ ]
+ }
+ },
+ "anyOf": [
+ {
+ "required": [
+ "query"
+ ]
+ },
+ {
+ "required": [
+ "properties"
+ ],
+ "properties": {
+ "properties": {
+ "type": "array",
+ "minItems": 1
+ }
+ }
+ }
+ ],
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/reconcile/tests/specs/0.2/reconciliation-result-batch.json b/reconcile/tests/specs/0.2/reconciliation-result-batch.json
new file mode 100644
index 00000000..dee41299
--- /dev/null
+++ b/reconcile/tests/specs/0.2/reconciliation-result-batch.json
@@ -0,0 +1,100 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/reconciliation-result-batch.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON serialization of any reconciliation result batch.",
+ "patternProperties": {
+ "^.*$": {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Entity identifier of the candidate"
+ },
+ "name": {
+ "type": "string",
+ "description": "Entity name of the candidate"
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description of the candidate entity"
+ },
+ "score": {
+ "type": "number",
+ "description": "Number indicating how likely it is that the candidate matches the query"
+ },
+ "features": {
+ "type": "array",
+ "description": "A list of features which can be used to derive a matching score",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique string identifier for the feature"
+ },
+ "value": {
+ "description": "The value of the feature for this reconciliation candidate",
+ "oneOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "match": {
+ "type": "boolean",
+ "description": "Boolean value indicating whether the candiate is a certain match or not."
+ },
+ "type": {
+ "type": "array",
+ "description": "Types the candidate entity belongs to",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A type can be given by id and name",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ {
+ "type": "string",
+ "description": "Alternatively, if only a string is given, it is treated as the id"
+ }
+ ]
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "score"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+ }
+ }
+}
diff --git a/reconcile/tests/specs/0.2/suggest-entities-response.json b/reconcile/tests/specs/0.2/suggest-entities-response.json
new file mode 100644
index 00000000..7a481d78
--- /dev/null
+++ b/reconcile/tests/specs/0.2/suggest-entities-response.json
@@ -0,0 +1,62 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/suggest-entities-response.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a suggest service for entities.",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested entity"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested entity"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ },
+ "notable": {
+ "type": "array",
+ "description": "Types the suggest entity belongs to",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A type can be given by id and name",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id"
+ ]
+ },
+ {
+ "type": "string",
+ "description": "Alternatively, if only a string is given, it is treated as the id"
+ }
+ ]
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/suggest-properties-response.json b/reconcile/tests/specs/0.2/suggest-properties-response.json
new file mode 100644
index 00000000..7ec62aa5
--- /dev/null
+++ b/reconcile/tests/specs/0.2/suggest-properties-response.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/suggest-properties-response.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a suggest service for properties.",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested property"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested property"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+ }
+ }
+ },
+ "required": [
+ "result"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/suggest-types-response.json b/reconcile/tests/specs/0.2/suggest-types-response.json
new file mode 100644
index 00000000..c1171dfc
--- /dev/null
+++ b/reconcile/tests/specs/0.2/suggest-types-response.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json-schema.org/schema#",
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/suggest-types-response.json",
+ "type": "object",
+ "description": "This schema can be used to validate the JSON response of a suggest service for types.",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": { "$ref": "type.json" }
+ }
+ },
+ "required": [
+ "result"
+ ]
+}
diff --git a/reconcile/tests/specs/0.2/type.json b/reconcile/tests/specs/0.2/type.json
new file mode 100644
index 00000000..fdc5e7f3
--- /dev/null
+++ b/reconcile/tests/specs/0.2/type.json
@@ -0,0 +1,29 @@
+{
+ "$id": "https://reconciliation-api.github.io/specs/0.2/schemas/type.json",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Identifier of the suggested type"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the suggested type"
+ },
+ "description": {
+ "type": "string",
+ "description": "An optional description which can be provided to disambiguate namesakes, providing more context."
+ },
+ "broader": {
+ "type": "array",
+ "description": "An optional array of types, each representing a direct (i.e., immediate) broader category of entities.",
+ "items": {
+ "$ref": "type.json"
+ }
+ }
+ },
+ "required": [
+ "id",
+ "name"
+ ]
+}
diff --git a/reconcile/tests/test_company_api.py b/reconcile/tests/test_company_api.py
new file mode 100644
index 00000000..160e6422
--- /dev/null
+++ b/reconcile/tests/test_company_api.py
@@ -0,0 +1,103 @@
+import json
+import logging
+import os
+
+import jsonschema
+
+from ftc.tests import TestCase
+
+from .conftest import get_schema
+
+logger = logging.getLogger(__name__)
+
+with open(
+ os.path.join(os.path.dirname(__file__), "data", "company_reconcile_response.json")
+) as f:
+ RECON_RESPONSE = json.load(f)
+
+with open(
+ os.path.join(os.path.dirname(__file__), "data", "reconcile_response_empty.json")
+) as f:
+ EMPTY_RESPONSE = json.load(f)
+
+
+class TestCompanyReconcileAPI(TestCase):
+ # GET request to /api/v1/reconcile/company should return the service spec
+ def test_get_company_service_spec(self):
+ for schema_version, schema in get_schema("manifest.json").items():
+ with self.subTest(schema_version):
+ response = self.client.get("/api/v1/reconcile/company")
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ data,
+ {
+ "versions": ["0.2"],
+ "name": "Find that Charity Company Reconciliation API",
+ "identifierSpace": "http://org-id.guide",
+ "schemaSpace": "https://schema.org",
+ "defaultTypes": [
+ {"id": "/registered-company", "name": "Registered Company"}
+ ],
+ "view": {"url": "http://testserver/company/{{id}}"},
+ },
+ )
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # POST request to /api/v1/reconcile/company should return a list of candidates
+ def test_company_reconcile(self):
+ # attach RECON RESPONSE to the search() method of self.mock_es
+ self.mock_es.return_value.search.return_value = RECON_RESPONSE
+
+ for schema_version, schema in get_schema(
+ "reconciliation-result-batch.json"
+ ).items():
+ with self.subTest(schema_version):
+ response = self.client.post(
+ "/api/v1/reconcile/company",
+ {
+ "queries": json.dumps({"q0": {"query": "Test"}}),
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(list(data.keys()), ["q0"])
+ self.assertEqual(len(data["q0"]["result"]), 10)
+ self.assertEqual(data["q0"]["result"][0]["id"], "12345670")
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # POST request to /api/v1/reconcile should return a list of candidates
+ def test_reconcile_empty(self):
+ # attach RECON RESPONSE to the search() method of self.mock_es
+ self.mock_es.return_value.search.return_value = EMPTY_RESPONSE
+
+ for schema_version, schema in get_schema(
+ "reconciliation-result-batch.json"
+ ).items():
+ with self.subTest(schema_version):
+ response = self.client.post(
+ "/api/v1/reconcile/company",
+ {
+ "queries": json.dumps({"q0": {"query": "Test"}}),
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(list(data.keys()), ["q0"])
+ self.assertEqual(len(data["q0"]["result"]), 0)
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
diff --git a/reconcile/tests/test_reconcile_all_api.py b/reconcile/tests/test_reconcile_all_api.py
new file mode 100644
index 00000000..479e6544
--- /dev/null
+++ b/reconcile/tests/test_reconcile_all_api.py
@@ -0,0 +1,231 @@
+import json
+import logging
+import os
+from typing import Tuple
+
+import jsonschema
+
+from ftc.tests import TestCase
+
+from .conftest import get_schema
+
+logger = logging.getLogger(__name__)
+
+with open(
+ os.path.join(os.path.dirname(__file__), "data", "all_reconcile_response.json")
+) as f:
+ RECON_RESPONSE = json.load(f)
+
+with open(
+ os.path.join(os.path.dirname(__file__), "data", "reconcile_response_empty.json")
+) as f:
+ EMPTY_RESPONSE = json.load(f)
+
+with open(
+ os.path.join(os.path.dirname(__file__), "data", "all_suggest_response.json")
+) as f:
+ SUGGEST_RESPONSE = json.load(f)
+
+
+RECON_BASE_URLS: list[Tuple[str, list[str]]] = [
+ ("/api/v1/reconcile/", ["0.2"]),
+ ("/api/v1/reconcile/local-authority", ["0.2"]),
+ ("/reconcile", ["0.1"]),
+ ("/reconcile/local-authority", ["0.1"]),
+]
+
+
+def get_test_cases(schema_file):
+ for base_url, base_url_schema_version in RECON_BASE_URLS:
+ for schema_version, schema in get_schema(
+ schema_file, supported_api_versions=base_url_schema_version
+ ).items():
+ yield base_url, schema_version, schema
+
+
+class TestReconcileAllAPI(TestCase):
+ # GET request to /api/v1/reconcile should return the service spec
+ def test_get_service_spec(self):
+ for base_url, schema_version, schema in get_test_cases("manifest.json"):
+ with self.subTest((base_url, schema_version)):
+ response = self.client.get(base_url)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ if not base_url.endswith("/"):
+ base_url += "/"
+ if schema_version != "0.1":
+ self.assertEqual(data["versions"], ["0.2"])
+ self.assertEqual(data["identifierSpace"], "http://org-id.guide")
+ self.assertTrue("find that charity" in data["name"].lower())
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # POST request to /api/v1/reconcile should return a list of candidates
+ def test_reconcile(self):
+ # attach RECON RESPONSE to the search() method of self.mock_es
+ self.mock_es.return_value.search.return_value = RECON_RESPONSE
+
+ for base_url, schema_version, schema in get_test_cases(
+ "reconciliation-result-batch.json"
+ ):
+ with self.subTest((base_url, schema_version)):
+ response = self.client.post(
+ base_url,
+ {
+ "queries": json.dumps({"q0": {"query": "Test"}}),
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(list(data.keys()), ["q0"])
+ self.assertEqual(len(data["q0"]["result"]), 10)
+ self.assertEqual(data["q0"]["result"][0]["id"], "GB-CHC-1006706")
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # POST request to /api/v1/reconcile should return a list of candidates
+ def test_reconcile_empty(self):
+ # attach RECON RESPONSE to the search() method of self.mock_es
+ self.mock_es.return_value.search.return_value = EMPTY_RESPONSE
+
+ for base_url, schema_version, schema in get_test_cases(
+ "reconciliation-result-batch.json"
+ ):
+ with self.subTest((base_url, schema_version)):
+ response = self.client.post(
+ base_url,
+ {
+ "queries": json.dumps({"q0": {"query": "Test"}}),
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(list(data.keys()), ["q0"])
+ self.assertEqual(len(data["q0"]["result"]), 0)
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # POST request to /api/v1/reconcile/suggest/entity should return a list of candidates
+ def test_reconcile_suggest_entity(self):
+ self.mock_es.return_value.search.return_value = SUGGEST_RESPONSE
+
+ for base_url, schema_version, schema in get_test_cases(
+ "suggest-entities-response.json"
+ ):
+ with self.subTest((base_url, schema_version)):
+ service_spec = self.client.get(base_url).json()
+ suggest_url = "".join(list(service_spec["suggest"]["entity"].values()))
+
+ response = self.client.get(
+ suggest_url,
+ {
+ "prefix": "Test",
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(list(data.keys()), ["result"])
+ self.assertEqual(len(data["result"]), 5)
+ self.assertEqual(
+ list(data["result"][0].keys()), ["id", "name", "notable"]
+ )
+ self.assertEqual(data["result"][0]["id"], "GB-CHC-1110225")
+ self.assertEqual(len(data["result"][0]["notable"]), 4)
+ self.assertTrue("registered-charity" in data["result"][0]["notable"])
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # POST request to /api/v1/reconcile/suggest/entity should return a list of candidates
+ def test_reconcile_extend(self):
+ self.mock_es.return_value.search.return_value = SUGGEST_RESPONSE
+
+ for base_url, schema_version, schema in get_test_cases(
+ "data-extension-response.json"
+ ):
+ with self.subTest((base_url, schema_version)):
+ response = self.client.post(
+ base_url,
+ {
+ "extend": json.dumps(
+ {
+ "ids": ["GB-CHC-1234", "GB-CHC-5"],
+ "properties": [
+ {
+ "id": "name",
+ },
+ {
+ "id": "vocab-test-vocab",
+ },
+ ],
+ }
+ )
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(list(data.keys()), ["meta", "rows"])
+ self.assertEqual(data["meta"], [{"id": "name", "name": "Name"}])
+ self.assertEqual(list(data["rows"].keys()), ["GB-CHC-1234", "GB-CHC-5"])
+ self.assertEqual(
+ data["rows"]["GB-CHC-1234"]["name"], [{"str": "Test organisation"}]
+ )
+ self.assertEqual(
+ data["rows"]["GB-CHC-1234"]["vocab-test-vocab"],
+ [{"str": "A"}, {"str": "B"}, {"str": "C"}],
+ )
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
+
+ # GET request to /api/v1/reconcile/suggest/entity should return a list of candidates
+ def test_reconcile_propose_properties(self):
+ self.mock_es.return_value.search.return_value = SUGGEST_RESPONSE
+
+ for base_url, schema_version, schema in get_test_cases(
+ "data-extension-property-proposal.json"
+ ):
+ with self.subTest((base_url, schema_version)):
+ service_spec = self.client.get(base_url).json()
+ properties_url = "".join(
+ list(service_spec["extend"]["propose_properties"].values())
+ )
+
+ response = self.client.get(
+ properties_url,
+ dict(type="Organization"),
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(
+ sorted(data.keys()), sorted(["properties", "type", "limit"])
+ )
+ self.assertEqual(data["type"], "Organization")
+ self.assertGreater(len(data["properties"]), 30)
+ ids = [p["id"] for p in data["properties"]]
+ self.assertTrue("dateRegistered" in ids)
+ self.assertTrue("vocab-test-vocab" in ids)
+
+ jsonschema.validate(
+ instance=data,
+ schema=schema,
+ cls=jsonschema.Draft7Validator,
+ )
diff --git a/reconcile/utils.py b/reconcile/utils.py
new file mode 100644
index 00000000..58f8394d
--- /dev/null
+++ b/reconcile/utils.py
@@ -0,0 +1,24 @@
+def convert_value(v):
+ if isinstance(v, dict):
+ return v
+ elif isinstance(v, list):
+ new_list = []
+ for vv in v:
+ new_value = convert_value(vv)
+ if isinstance(new_value, list):
+ new_list.extend(new_value)
+ else:
+ new_list.append(new_value)
+ return new_list
+ elif isinstance(v, str):
+ return [{"str": v}]
+ elif isinstance(v, int):
+ return [{"int": v}]
+ elif isinstance(v, float):
+ return [{"float": v}]
+ elif isinstance(v, bool):
+ return [{"bool": v}]
+ elif v is None:
+ return [{}]
+ else:
+ return [{"str": str(v)}]
diff --git a/reconcile/views.py b/reconcile/views.py
index a9528c7a..951f0d8b 100644
--- a/reconcile/views.py
+++ b/reconcile/views.py
@@ -70,14 +70,14 @@ def service_spec(request, orgtypes=None):
"defaultTypes": defaultTypes,
"extend": {
"propose_properties": {
- "service_url": request.build_absolute_uri(reverse("index")),
+ "service_url": request.build_absolute_uri(reverse("index")).rstrip("/"),
"service_path": reverse("propose_properties"),
},
"property_settings": [],
},
"suggest": {
"entity": {
- "service_url": request.build_absolute_uri(reverse("index")),
+ "service_url": request.build_absolute_uri(reverse("index")).rstrip("/"),
"service_path": reverse("suggest"),
# "flyout_service_path": "/suggest/flyout/${id}"
}
@@ -180,10 +180,7 @@ def suggest(request, orgtype=None):
{
"id": r["_source"]["org_id"],
"name": r["_source"]["name"],
- "url": request.build_absolute_uri(
- reverse("orgid_html", kwargs={"org_id": r["_source"]["org_id"]})
- ),
- "orgtypes": list(r["_source"]["organisationType"]),
+ "notable": list(r["_source"]["organisationType"]),
}
for r in result.suggest[SUGGEST_NAME][0]["options"]
]
diff --git a/requirements.in b/requirements.in
index 29f3e88b..27468feb 100644
--- a/requirements.in
+++ b/requirements.in
@@ -46,4 +46,7 @@ sqlite-utils
git+https://github.com/kanedata/charity-django.git@v0.4.1
certifi
xlsxwriter
-boto3
\ No newline at end of file
+boto3
+jsonschema
+pytest
+pytest-django
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 40dd9280..d13d1c07 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -15,6 +15,8 @@ asgiref==3.6.0
attrs==22.2.0
# via
# cattrs
+ # jsonschema
+ # referencing
# requests-cache
babel==2.11.0
# via -r requirements.in
@@ -54,6 +56,7 @@ click-default-group-wheel==1.2.2
colorama==0.4.6
# via
# click
+ # pytest
# tqdm
confusable-homoglyphs==3.2.0
# via django-registration
@@ -118,7 +121,9 @@ elasticsearch-dsl==7.4.0
et-xmlfile==1.1.0
# via openpyxl
exceptiongroup==1.1.3
- # via cattrs
+ # via
+ # cattrs
+ # pytest
fake-useragent==1.1.1
# via requests-html
faker==16.7.0
@@ -135,12 +140,18 @@ inflect==7.0.0
# via
# -r requirements.in
# charity-django
+iniconfig==2.0.0
+ # via pytest
jinja2==3.1.2
# via -r requirements.in
jmespath==1.0.1
# via
# boto3
# botocore
+jsonschema==4.21.1
+ # via -r requirements.in
+jsonschema-specifications==2023.12.1
+ # via jsonschema
lml==0.1.0
# via pyexcel-io
lxml==4.9.2
@@ -161,10 +172,14 @@ oauthlib==3.2.2
# tweepy
openpyxl==3.1.0
# via -r requirements.in
+packaging==23.2
+ # via pytest
parse==1.19.0
# via requests-html
pillow==9.4.0
# via django-markdownx
+pluggy==1.4.0
+ # via pytest
psycopg2==2.9.5
# via -r requirements.in
psycopg2-binary==2.9.5
@@ -190,6 +205,12 @@ pyppeteer==1.0.2
# via requests-html
pyquery==2.0.0
# via requests-html
+pytest==8.0.0
+ # via
+ # -r requirements.in
+ # pytest-django
+pytest-django==4.8.0
+ # via -r requirements.in
python-dateutil==2.8.2
# via
# botocore
@@ -203,6 +224,10 @@ pytz==2022.7.1
# via babel
pyyaml==6.0
# via -r requirements.in
+referencing==0.33.0
+ # via
+ # jsonschema
+ # jsonschema-specifications
requests==2.31.0
# via
# -r requirements.in
@@ -224,6 +249,10 @@ requests-mock==1.10.0
# via -r requirements.in
requests-oauthlib==1.3.1
# via tweepy
+rpds-py==0.17.1
+ # via
+ # jsonschema
+ # referencing
ruff==0.1.3
# via -r requirements.in
s3transfer==0.8.2
@@ -257,6 +286,8 @@ titlecase==2.4
# via
# -r requirements.in
# charity-django
+tomli==2.0.1
+ # via pytest
tqdm==4.64.1
# via
# -r requirements.in
diff --git a/templates/ninja/swagger.html b/templates/ninja/swagger.html
index aadbe949..5397a260 100644
--- a/templates/ninja/swagger.html
+++ b/templates/ninja/swagger.html
@@ -93,6 +93,10 @@
.swagger-ui .renderedMarkdown p {
margin-top: 0;
}
+.swagger-ui .opblock-tag .renderedMarkdown p {
+ margin-top: 0;
+ margin-bottom: 0;
+}
.swagger-ui .json-schema-2020-12 [type=button] {
background-color: transparent;
}