Skip to content

Commit

Permalink
API - v2 search endpoints
Browse files Browse the repository at this point in the history
Signed-off-by: Kial Jinnah <kialj876@gmail.com>
  • Loading branch information
kialj876 committed Aug 6, 2024
1 parent 3ae43ee commit 1260eed
Show file tree
Hide file tree
Showing 13 changed files with 987 additions and 4 deletions.
3 changes: 2 additions & 1 deletion search-api/src/search_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from search_api import errorhandlers, models
from search_api.config import config
from search_api.models import db
from search_api.resources import v1_endpoint
from search_api.resources import v1_endpoint, v2_endpoint
from search_api.services import Flags, business_solr, queue
from search_api.translations import babel
from search_api.utils.auth import jwt
Expand Down Expand Up @@ -68,6 +68,7 @@ def create_app(config_name: str = os.getenv('APP_ENV') or 'production', **kwargs
migrate.init_app(app, db)

v1_endpoint.init_app(app)
v2_endpoint.init_app(app)
setup_jwt_manager(app, jwt)

@app.before_request
Expand Down
1 change: 1 addition & 0 deletions search-api/src/search_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class Config(): # pylint: disable=too-few-public-methods
SOLR_SVC_BUS_FOLLOWER_CORE = os.getenv('SOLR_SVC_BUS_FOLLOWER_CORE', 'business_follower')
SOLR_SVC_BUS_LEADER_URL = os.getenv('SOLR_SVC_BUS_LEADER_URL', 'http://localhost:8873/solr')
SOLR_SVC_BUS_FOLLOWER_URL = os.getenv('SOLR_SVC_BUS_FOLLOWER_URL', 'http://localhost:8873/solr')
SOLR_SVC_BUS_MAX_ROWS = int(os.getenv('SOLR_SVC_BUS_MAX_ROWS', '10000'))

PAYMENT_SVC_URL = os.getenv('PAYMENT_SVC_URL', 'http://')
AUTH_SVC_URL = os.getenv('AUTH_SVC_URL', 'http://')
Expand Down
3 changes: 2 additions & 1 deletion search-api/src/search_api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"""Exposes the versioned endpoints."""
from .constants import EndpointVersionPath
from .v1 import bus_bp, internal_bp, meta_bp, ops_bp, purchases_bp
from .v2 import search_bp
from .version_endpoint import VersionEndpoint


Expand All @@ -25,4 +26,4 @@
v2_endpoint = VersionEndpoint( # pylint: disable=invalid-name
name='API_V2',
path=EndpointVersionPath.API_V2,
bps=[])
bps=[search_bp])
8 changes: 6 additions & 2 deletions search-api/src/search_api/resources/v1/businesses/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ def facets(): # pylint: disable=too-many-branches, too-many-locals
'start': start or 0
},
'totalResults': results.get('response', {}).get('numFound'),
'results': results.get('response', {}).get('docs')}}
'results': results.get('response', {}).get('docs')},
'warnings': ['This endpoint is depreciated. Please use POST /api/v2/search/businesses instead.']}

return jsonify(response), HTTPStatus.OK

Expand Down Expand Up @@ -287,7 +288,10 @@ def parties(): # pylint: disable=too-many-branches, too-many-return-statements,
'rows': rows or business_solr.default_rows,
'start': start or 0},
'totalResults': results.get('response', {}).get('numFound'),
'results': results.get('response', {}).get('docs')}}
'results': results.get('response', {}).get('docs')
},
'warnings': ['This endpoint is depreciated. Please use POST /api/v2/search/parties instead.']
}

return jsonify(response), HTTPStatus.OK

Expand Down
15 changes: 15 additions & 0 deletions search-api/src/search_api/resources/v2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright © 2022 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Exposes all of the resource v2 endpoints in Flask-Blueprint style."""
from .search import bp as search_bp
23 changes: 23 additions & 0 deletions search-api/src/search_api/resources/v2/search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright © 2024 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API endpoints for Search."""
from flask import Blueprint

from .businesses import bp as businesses_bp
from .parties import bp as parties_bp


bp = Blueprint('SEARCH', __name__, url_prefix='/search') # pylint: disable=invalid-name
bp.register_blueprint(businesses_bp)
bp.register_blueprint(parties_bp)
113 changes: 113 additions & 0 deletions search-api/src/search_api/resources/v2/search/businesses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Copyright © 2024 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API endpoints for Search."""
from http import HTTPStatus

from flask import jsonify, Blueprint
from flask_cors import cross_origin

import search_api.resources.utils as resource_utils
from search_api.exceptions import SolrException
from search_api.services import business_solr
from search_api.services.base_solr.utils import QueryParams, parse_facets, prep_query_str
from search_api.services.business_solr.doc_fields import BusinessField, PartyField
from search_api.services.business_solr.utils import business_search
from search_api.utils.validators import validate_search_request


bp = Blueprint('BUSINESSES', __name__, url_prefix='/businesses') # pylint: disable=invalid-name


@bp.post('')
@cross_origin(origin='*')
def businesses():
"""Return a list of business results."""
try:
request_json, errors = validate_search_request()
if errors:
return resource_utils.bad_request_response('Errors processing request.', errors)
# set base query params
query_json: dict = request_json.get('query', {})
value = query_json.get('value', None)
query = {
'value': prep_query_str(value, True),
BusinessField.NAME_SINGLE.value: prep_query_str(query_json.get(BusinessField.NAME.value, '')),
BusinessField.IDENTIFIER_Q.value: prep_query_str(query_json.get(BusinessField.IDENTIFIER.value, '')),
BusinessField.BN_Q.value: prep_query_str(query_json.get(BusinessField.BN.value, ''))
}
# set child query params
child_query = {
PartyField.PARTY_NAME_SINGLE.value: query_json.get('parties', {}).get(PartyField.PARTY_NAME.value, '')
}
# set faceted category params
categories_json: dict = request_json.get('categories', {})
categories = {
BusinessField.TYPE: categories_json.get(BusinessField.TYPE.value, None),
BusinessField.STATE: categories_json.get(BusinessField.STATE.value, None)
}

# set doc fields to return
fields = business_solr.business_with_parties_fields
# create solr search params obj from parsed params
params = QueryParams(query=query,
start=request_json.get('start', business_solr.default_start),
rows=request_json.get('rows', business_solr.default_rows),
categories=categories,
fields=fields,
query_fields={
BusinessField.NAME_Q: 'parent',
BusinessField.NAME_STEM_AGRO: 'parent',
BusinessField.NAME_SINGLE: 'parent',
BusinessField.NAME_XTRA_Q: 'parent',
BusinessField.BN_Q: 'parent',
BusinessField.IDENTIFIER_Q: 'parent'},
query_boost_fields={
BusinessField.NAME_Q: 2,
BusinessField.NAME_STEM_AGRO: 2,
BusinessField.NAME_SINGLE: 2},
query_fuzzy_fields={
BusinessField.NAME_Q: {'short': 1, 'long': 2},
BusinessField.NAME_STEM_AGRO: {'short': 1, 'long': 2},
BusinessField.NAME_SINGLE: {'short': 1, 'long': 2}},
child_query=child_query,
child_categories={},
child_date_ranges={})
# execute search
results = business_search(params, business_solr)
response = {
'facets': parse_facets(results),
'searchResults': {
'queryInfo': {
'query': {
'value': query['value'],
BusinessField.NAME.value: query[BusinessField.NAME_SINGLE.value] or '',
BusinessField.IDENTIFIER.value: query[BusinessField.IDENTIFIER_Q.value] or '',
BusinessField.BN.value: query[BusinessField.BN_Q.value] or ''
},
'categories': {
BusinessField.TYPE.value: categories.get(BusinessField.TYPE, ''),
BusinessField.STATE.value: categories.get(BusinessField.STATE, '')},
'rows': params.rows,
'start': params.start
},
'totalResults': results.get('response', {}).get('numFound'),
'results': results.get('response', {}).get('docs')},
}

return jsonify(response), HTTPStatus.OK

except SolrException as solr_exception:
return resource_utils.exception_response(solr_exception)
except Exception as default_exception: # noqa: B902
return resource_utils.default_exception_response(default_exception)
122 changes: 122 additions & 0 deletions search-api/src/search_api/resources/v2/search/parties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Copyright © 2024 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API endpoints for Search."""
from http import HTTPStatus

from flask import jsonify, Blueprint
from flask_cors import cross_origin

from search_api.exceptions import SolrException
from search_api.services import business_solr
from search_api.services.base_solr.utils import QueryParams, parse_facets, prep_query_str
from search_api.services.business_solr.doc_fields import PartyField
from search_api.services.business_solr.utils import parties_search
import search_api.resources.utils as resource_utils
from search_api.utils.validators import validate_search_request


bp = Blueprint('PARTIES', __name__, url_prefix='/parties') # pylint: disable=invalid-name


@bp.post('')
@cross_origin(origin='*')
def parties():
"""Return a list of parties results."""
try:
request_json, errors = validate_search_request()
if errors:
return resource_utils.bad_request_response('Errors processing request.', errors)
# set base query params
query_json: dict = request_json.get('query', {})
value = query_json.get('value', None)
query = {
'value': prep_query_str(value, True),
PartyField.PARTY_NAME_SINGLE.value: prep_query_str(query_json.get(PartyField.PARTY_NAME.value, '')),
PartyField.PARENT_NAME_SINGLE.value: prep_query_str(query_json.get(PartyField.PARENT_NAME.value, '')),
PartyField.PARENT_IDENTIFIER_Q.value: prep_query_str(query_json.get(
PartyField.PARENT_IDENTIFIER.value, '')),
PartyField.PARENT_BN_Q.value: prep_query_str(query_json.get(PartyField.PARENT_BN.value, ''))
}

# set faceted category params
categories_json: dict = request_json.get('categories', {})
categories = {
PartyField.PARENT_TYPE: categories_json.get(PartyField.PARENT_TYPE.value, None),
PartyField.PARENT_STATE: categories_json.get(PartyField.PARENT_STATE.value, None),
PartyField.PARTY_ROLE: categories_json.get(PartyField.PARTY_ROLE.value, None)
}

# validate party roles
if not (party_roles := categories.get(PartyField.PARTY_ROLE)):
errors = [{'Invalid payload': f"Expected 'categories/{PartyField.PARTY_ROLE.value}:[...]'."}]
return resource_utils.bad_request_response('Errors processing request.', errors)

if [x for x in party_roles if x.lower() not in ['partner', 'proprietor']]:
errors = [{
'Invalid payload':
f"Expected 'categories/{PartyField.PARTY_ROLE.value}:' with values 'partner' and/or " +
"'proprietor'. Other party roles are not available."
}]
return resource_utils.bad_request_response('Errors processing request.', errors)

params = QueryParams(query=query,
start=int(request_json.get('start', business_solr.default_start)),
rows=int(request_json.get('rows', business_solr.default_rows)),
categories=categories,
fields=business_solr.party_fields,
query_fields={
PartyField.PARTY_NAME_Q: 'parent',
PartyField.PARTY_NAME_STEM_AGRO: 'parent',
PartyField.PARTY_NAME_SINGLE: 'parent',
PartyField.PARTY_NAME_XTRA_Q: 'parent'},
query_boost_fields={
PartyField.PARTY_NAME_Q: 2,
PartyField.PARTY_NAME_STEM_AGRO: 2,
PartyField.PARTY_NAME_SINGLE: 2},
query_fuzzy_fields={
PartyField.PARTY_NAME_Q: {'short': 1, 'long': 2},
PartyField.PARTY_NAME_STEM_AGRO: {'short': 1, 'long': 2},
PartyField.PARTY_NAME_SINGLE: {'short': 1, 'long': 2}},
child_query={},
child_categories={},
child_date_ranges={})
results = parties_search(params, business_solr)
response = {
'facets': parse_facets(results),
'searchResults': {
'queryInfo': {
'query': {
'value': query['value'],
PartyField.PARTY_NAME.value: query[PartyField.PARTY_NAME_SINGLE.value] or '',
PartyField.PARENT_NAME.value: query[PartyField.PARENT_NAME_SINGLE.value] or '',
PartyField.PARENT_IDENTIFIER.value: query[PartyField.PARENT_IDENTIFIER_Q.value] or '',
PartyField.PARENT_BN.value: query[PartyField.PARENT_BN_Q.value] or ''
},
'categories': {
PartyField.PARENT_TYPE.value: categories[PartyField.PARENT_TYPE],
PartyField.PARENT_STATE.value: categories[PartyField.PARENT_STATE],
PartyField.PARTY_ROLE.value: categories[PartyField.PARTY_ROLE]},
'rows': params.rows,
'start': params.start},
'totalResults': results.get('response', {}).get('numFound'),
'results': results.get('response', {}).get('docs')
}
}

return jsonify(response), HTTPStatus.OK

except SolrException as solr_exception:
return resource_utils.exception_response(solr_exception)
except Exception as default_exception: # noqa: B902
return resource_utils.default_exception_response(default_exception)
36 changes: 36 additions & 0 deletions search-api/src/search_api/utils/validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,44 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""This module holds request non-schema data validation functions and helpers."""
from flask import current_app, request


def valid_charset(word: str) -> bool:
"""Verify word characters adhere to a supported set."""
return word == word.encode('ascii', 'ignore').decode('utf-8')


def validate_search_request() -> tuple[dict, list[dict]]:
"""Validate the search request headers / payload."""
errors = []
request_json = request.get_json()
query_json = request_json.get('query', None)
if not isinstance(query_json, dict):
errors.append({'Invalid payload': "Expected an object for 'query'."})
else:
value = query_json.get('value', None)
if not value or not isinstance(value, str):
errors.append({'Invalid payload': "Expected a string for 'query/value'."})

if not isinstance(query_json.get('parties', {}), dict):
errors.append({'Invalid payload': "Expected an object for 'query/parties'."})

categories = request_json.get('categories', {})
if not isinstance(categories, dict):
errors.append({'Invalid payload': "Expected an object for 'categories'."})
else:
for key, value in categories.items():
if not isinstance(value, list):
errors.append({'Invalid payload': f"Expected a list for 'categories/{key}'."})
try:
start = int(request_json.get('start', 0))
rows = int(request_json.get('rows', 0))
if start < 0:
errors.append({'Invalid payload': "Expected 'start' to be >= 0."})
if rows > (max_rows := current_app.config['SOLR_SVC_BUS_MAX_ROWS']) or rows < 0:
errors.append({'Invalid payload': f"Expected 'rows' to be between 0 and {max_rows}."})
except ValueError: # catch invalid start/row entry
errors.append({'Invalid payload': "Expected integer for params: 'start', 'rows'"})

return request_json, errors
14 changes: 14 additions & 0 deletions search-api/tests/unit/api/v2/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright © 2024 Province of British Columbia
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Test-Suite for the v2 API."""
Loading

0 comments on commit 1260eed

Please sign in to comment.