Enhancing Code Quality: Refactoring Python Endpoints and JavaScript Fetch Methods through Abstraction
Introduction:
In the ever-changing landscape of software development, the need for code refactoring remains constant. Similar to urban planning adapting to modern needs, codebases must evolve to meet new requirements and technological advancements. That's where code refactoring comes into play! It's a practical method to modernize legacy applications, keeping them in prime condition without significant cost implications or performance drawbacks. In this article, we'll delve into the essence of code refactoring, its importance, and how it can be applied to both Python endpoints and JavaScript fetch methods. This post explores the art of code refactoring, its significance, methods, and practical applications in Python and JavaScript development.
Significance of Code Refactoring:
At its core, code refactoring involves restructuring existing code without altering its external behavior. This process targets "code smells" – maintainability issues that could render your code confusing or problematic – and rectifies them. The goal is to enhance code readability and maintainability while reducing complexity, ensuring that your application behaves consistently. Just as renovating a building modernizes it while preserving its historical charm, code refactoring revitalizes aging codebases. There are many reasons as to why you would want to refactor your code but we will go into only four main reasons.
Reduced Duplication and Improved Consistency:
Refactoring reduces duplication and ensures consistency by consolidating common functionality into reusable components like the BaseResource in Python (BaseResource code example) or custom hooks in JavaScript (useFetchJSON) that we will discuss. This consolidation fosters cleaner, more maintainable code, akin to decluttering a bustling city street.
Simplified Logic and Enhanced Scalability:
Refactored code with modular components simplifies logic and promotes scalability, much like a well-planned infrastructure that accommodates growth. Modular structures facilitate the addition of new features and modifications without causing ripple effects throughout the application, adapting to evolving development needs.
Helper functions:
The use of helper functions in software development serves to simplify complex logic and promote code reusability. In the provided code example, a set of helper functions is defined to interact with a database, encapsulating common operations such as querying for all instances of a model, retrieving a specific instance by its ID, and fetching instances based on a given condition. By abstracting these database interactions into reusable functions, developers can streamline their code and avoid redundancy. For instance, the get_all
function centralizes the process of fetching all instances of a particular model, while get_instance_by_id
simplifying the retrieval of a single instance based on its unique identifier. Similarly, get_all_by_condition
provides a flexible solution for fetching instances based on custom conditions. This modular approach not only enhances code readability but also facilitates easier maintenance and debugging. Additionally, by separating database-related logic into standalone functions, developers can achieve better organization and maintain a clearer separation of concerns within their codebases. We will go into various examples of helpers being utilized in both Python and JavaScript and in both situations we will utilize a similar formatting where we outline the structure of what the function should expect along with what it should do each time it processes that request.
#! helpers
#Python Flask SQL Alchemy 2.0 context
def execute_query(query):
"""
Executes the provided SQLAlchemy query and returns the result as scalar values.
Args:
query: SQLAlchemy query to execute.
Returns:
Result of the query as scalar values.
"""
return db.session.execute(query).scalars()
def get_all(model):
"""
Retrieves all instances of the specified model from the database.
Args:
model: SQLAlchemy model class.
Returns:
List of all instances of the model.
"""
# Equivalent to: return db.session.execute(select(model)).scalars().all()
return execute_query(select(model)).all()
def get_instance_by_id(model, id):
"""
Retrieves an instance of the specified model by its ID from the database.
Args:
model: SQLAlchemy model class.
id: ID of the instance to retrieve.
Returns:
Instance of the model if found, else None.
"""
instance = db.session.get(model, id)
if instance:
return instance
else:
return None
def get_one_by_condition(model, condition):
"""
Retrieves a single instance of the specified model based on the provided condition from the database.
Args:
model: SQLAlchemy model class.
condition: Condition to filter the instance.
Returns:
Single instance of the model.
"""
# Equivalent to:
# stmt = select(model).where(condition)
# result = db.session.execute(stmt)
# return result.scalars().first()
return execute_query(select(model).where(condition)).first()
def get_all_by_condition(model, condition):
"""
Retrieves all instances of the specified model based on the provided condition from the database.
Args:
model: SQLAlchemy model class.
condition: Condition to filter instances.
Returns:
List of instances of the model.
"""
# Equivalent to:
# stmt = select(model).where(condition)
# result = db.session.execute(stmt)
# return result.scalars().all()
return execute_query(select(model).where(condition)).all()
Refactoring Python Endpoints with BaseResource:
Let's first explore how refactoring Python endpoints using a base class, such as BaseResource, can streamline your codebase. By centralizing common CRUD operations like GET, POST, PATCH, and DELETE within BaseResource, code duplication across different endpoint classes is significantly reduced. This abstraction not only simplifies endpoint logic but also ensures consistent error handling throughout the application.
Consider the following example of a BaseResource implementation for handling user-related operations:
# BaseResource
class BaseResource:
model = None
schema = None
def get(self, id=None, condition=None):
"""
Retrieves data from the database based on the provided id or condition.
Args:
id (int): The id of the instance to retrieve.
condition (dict): A dictionary representing the conditions for data retrieval.
Returns:
tuple: A tuple containing serialized data and status code.
"""
try:
if id is None and condition is None:
# Get all instances from the model
instances = get_all(self.model)
return (
self.schema.dump(instances, many=True),
200,
) # Use the schema to serialize the instances
elif condition is not None:
# Get instances based on provided condition
instances = get_all_by_condition(self.model, condition)
return (
self.schema.dump(instances, many=True),
200,
) # Use the schema to serialize the instances
else:
# Get instance by id
instance = get_instance_by_id(self.model, id)
if instance is None:
return {"errors": f"{self.model.__name__} not found"}, 404
return (
self.schema.dump(instance),
200,
) # Use the schema to serialize the instance
except SQLAlchemyError as e:
db.session.rollback()
return {"errors": str(e)}, 500
def delete(self, id):
"""
Deletes an instance from the database based on the provided id.
Args:
id (int): The id of the instance to delete.
Returns:
tuple: A tuple containing empty string and status code.
"""
try:
instance = get_instance_by_id(self.model, id)
db.session.delete(instance)
db.session.commit()
return "", 204
except SQLAlchemyError as e:
db.session.rollback()
return {"errors": str(e)}, 500
def post(self, data):
"""
Creates a new instance in the database.
Args:
data (dict): A dictionary containing data for creating the instance.
Returns:
tuple: A tuple containing serialized data and status code.
"""
try:
data = self.schema.load(
request.json
) # Use the schema to deserialize the request data via load
instance = self.model(**data)
db.session.add(instance)
db.session.commit()
return (
self.schema.dump(instance),
201,
) # Use the schema to serialize the instance
except ValidationError as e:
return {"message": str(e)}, 422
except IntegrityError:
db.session.rollback()
return {"message": "Invalid data"}, 422
def patch(self, id):
"""
Updates an existing instance in the database.
Args:
id (int): The id of the instance to update.
Returns:
tuple: A tuple containing serialized data and status code.
"""
try:
data = self.schema.load(
data
) # Use the schema to deserialize the request data
instance = get_instance_by_id(self.model, id)
for key, value in data.items():
setattr(instance, key, value)
db.session.commit()
return (
self.schema.dump(instance),
200,
) # Use the schema to serialize the instance
except ValidationError as e:
return {"message": str(e)}, 422
except IntegrityError:
db.session.rollback()
return {"message": "Invalid data"}, 422
What makes this BaseResource helper so special regarding scalability is the only thing you have to do when utilizing it is call the resource correctly by passing the required data to it by using super. If you do want to do any type of logic prior to processing the request up to the BaseResource, you certainly can. Using the code example below, you will see how the flask RESTful endpoint class has each method listed that it will utilize the BaseResource for - through inheritance and then returning a super() function with the required arguments.
class UsersIndex(BaseResource):
model = User
schema = UserUpdateSchema()
def get(self):
if g.user is None:
return {"message": "Unauthorized"}, 401
return super().get(condition=(User.id == g.user.id))
# def post(self):
# pass
#endpoint does not need / utilize post - optional to pass or not include it
def patch(self, user_id=None):
if g.user is None:
return {"message": "Unauthorized"}, 401
# Get the current password from the request data
current_password = request.json.get('current_password')
if not current_password:
return {"message": "Current password is required"}, 400
# Check if the current password matches the stored password
if not g.user.authenticate(current_password):
return {"message": "Current password is incorrect"}, 400
# Hash the new password before storing it
new_password = request.json.get('password_hash')
if new_password:
g.user.password_hash = new_password
self.schema.context = {"is_update": True, "user_id": user_id}
return super().patch(user_id)
def delete(self):
if g.user is None:
return {"message": "Unauthorized"}, 401
return super().delete(g.user.id)
Optimizing JavaScript Fetch Methods with Custom Hooks
Now, let's shift our focus to optimizing JavaScript fetch methods using custom hooks. Without such hooks, each component that requires fetch operations would need to implement its fetch logic, leading to code duplication and inconsistency. By abstracting common fetch logic into custom hooks, code duplication is minimized. Imagine having multiple needs for these various non-refactored fetch calls and how many of these would end up throughout your code (forms, specific component logic, etc).
After looking at the non-refactored code example, consider the following example of a custom useFetchJSON hook for handling JSON fetch requests instead:
// Non-refactored GET request function
const fetchGet = async (url) => {
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching data:', error);
throw new Error('Failed to fetch data');
}
};
// Non-refactored POST request function
const fetchPost = async (url, formData) => {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const data = await response.json();
return data;
} catch (error) {
console.error('Error posting data:', error);
throw new Error('Failed to post data');
}
};
// Non-refactored PATCH request function
const fetchPatch = async (url, idOrIdEditingMode, formData) => {
try {
const response = await fetch(`${url}/${idOrIdEditingMode}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const data = await response.json();
return data;
} catch (error) {
console.error('Error patching data:', error);
throw new Error('Failed to patch data');
}
};
// Non-refactored DELETE request function
const fetchDelete = async (url) => {
try {
const response = await fetch(url, {
method: 'DELETE',
});
const data = await response.json();
return data;
} catch (error) {
console.error('Error deleting data:', error);
throw new Error('Failed to delete data');
}
};
//useFetchJSON custom hook code example
export const useFetchJSON = () => {
// Function to handle HTTP requests
const handleRequest = async (url, method, body = null) => {
// Define request headers
const headers = {
'Content-Type': 'application/json',
}
// Create configuration object for the fetch request
const configObj = {
method,
headers,
body: body ? JSON.stringify(body) : null,
}
try {
// Send the request and await the response
const res = await fetch(url, configObj)
// If response is not OK, throw an error
if (!res.ok) {
const errorBody = await res.json();
throw new Error(errorBody.message || 'Request Failed: status: ' + res.status)
}
// Return the response if successful
return res;
}
// Catch and handle any errors that occur during the request
catch (error) {
// Throw a custom error message
throw new Error('Failed to Fetch: Is the server running?')
}
}
// Function to make a POST request with JSON data
const postJSON = async (url, formData) => {
return await handleRequest(url, 'POST', formData)
}
// Function to make a PATCH request with JSON data
const patchJSON = async (url, idOrIdEditingMode, formData) => {
return await handleRequest(`${url}/${idOrIdEditingMode}`, 'PATCH', formData)
}
// Function to make a DELETE request
const deleteJSON = async (url) => {
return await handleRequest(`${url}`, 'DELETE')
}
// Return the POST, PATCH, and DELETE functions
return { postJSON, patchJSON, deleteJSON }
}
// Export the useFetchJSON hook if not already exported
export default useFetchJSON
In this example, useFetchJSON abstracts away common fetch logic, including error handling and loading state management. Components can then use this custom hook to perform JSON fetch requests with minimal boilerplate code, promoting code reuse and maintainability.
Additionally, you will need to utilize this custom hook. Here is an example of each fetch type being utilized:
import { useFetchJSON } from '../utils/helpers';
// .. additional component code here
const { deleteJSON, patchJSON, postJSON } = useFetchJSON();
// .. additional component code here
const handlePatchUser = async (id, updates) => {
setUsers(users.map(user => user.id === id ? { ...user, ...updates } : user))
try {
const result = await patchJSON(`/api/v1/${currentPage}`, id, updates)
if (!result.ok) {
throw new Error('Patch failed: status: ' + result.status)
}
} catch (err) {
console.log(err)
toast.error(err.message);
setUsers(currentUsers => currentUsers.map(user =>
user.id === id ? { ...user, ...revertUpdates(user, updates) } : user
))
}
function revertUpdates(user, updates) {
const revertedUpdates = {}
for (let key in updates) {
revertedUpdates[key] = user[key]
}
return revertedUpdates
}
}
const handleDeleteUser = async (id) => {
const userToDelete = users.find(user => user.id === id)
setUsers(users.filter(user => user.id !== id))
try {
const resp = await deleteJSON(`/api/v1/${currentPage}/${id}`)
if (resp.status === 204) {
console.log('User deleted successfully')
logout()
}
} catch (err) {
console.log(err)
setUsers(currentUsers => [...currentUsers, userToDelete])
}
}
const handlePostCharacter = async (newCampaign) => {
try {
const resp = await postJSON(`/api/v1/${currentPage}`, newCampaign);
if (resp.status === 201) {
const campaign = await resp.json();
setCharacters(prevCampaigns => [...prevCampaigns, campaign]);
console.log('Campaign created successfully');
} else {
throw new Error('Post failed: status: ' + resp.status);
}
} catch (err) {
console.log(err);
}
};
Conclusion
In conclusion, code refactoring plays a pivotal role in maintaining and enhancing code quality as applications evolve. By unifying Python endpoints with base classes like BaseResource and optimizing JavaScript fetch methods using custom hooks, developers can streamline their codebase, reduce duplication, and ensure consistency across their applications. Embracing these principles not only improves the readability and maintainability of code but also lays a solid foundation for scalability and future development endeavors.
References:
Flask Documentation
Node.js Documentation
React Documentation
Subscribe to my newsletter
Read articles from Vincent Revard directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by