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: